diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go index e7524a9..d8f15a3 100644 --- a/go/delivery/view_answers.go +++ b/go/delivery/view_answers.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "git.novox.be/novox/mesh-sdk/go/asks" ) // The asks the view may answer, and its answers (novox/hq ADR 0528 clause 6, ADR 0429 §3 and §6, ADR 0437 §10). The @@ -143,22 +145,97 @@ func ParseViewAnswerKey(key string) (askKey string, err error) { return askKey, nil } -// ViewAnswer is the view's answer to an ask: the option the operator chose and their words. The entry's own time -// says when. +// AssignerAsk is the value of a key in AssignerAsksBucket: the router's ask whole, as the assigner composes it, so the +// teller publishes it unchanged. Asking counts the times the assigner wrote the ask at this key, as the gate's Ask +// does: 0 or 1 for the first, n for the n-th, and the ask's id is AskingID(key, Asking), so each asking has its own id +// and an answer to an earlier asking is never taken for a later one. +type AssignerAsk struct { + V int `json:"v"` + Ask asks.Ask `json:"ask"` + Asking int `json:"asking,omitempty"` +} + +func (a AssignerAsk) check(key string) error { + ask, err := ParseAnyAskKey(key) + if err != nil { + return err + } + if proof, _ := AskNeedsProof(ask.Kind); proof { + return fmt.Errorf("delivery: the assigner asks no %s ask: it needs proof of who answered", ask.Kind) + } + if a.Asking < 0 { + return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking) + } + if want := AskingID(key, a.Asking); a.Ask.ID != want { + return fmt.Errorf("delivery: the ask's id %q is not %q, the id its key and asking %d give", a.Ask.ID, want, a.Asking) + } + if len(a.Ask.Options) == 0 { + return fmt.Errorf("delivery: the ask has no option") + } + return nil +} + +// EncodeAssignerAsk is a, the ask at key, as JSON at Version; it refuses a key of a kind that needs proof or of no +// kind, an id other than the one the key and the asking give, and an ask with no option. +func EncodeAssignerAsk(key string, a AssignerAsk) ([]byte, error) { + if err := a.check(key); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAssignerAsk reads the ask at key; it refuses an unknown version (ErrUnknownVersion) and what EncodeAssignerAsk +// refuses. +func DecodeAssignerAsk(key string, data []byte) (AssignerAsk, error) { + var a AssignerAsk + if err := decode(data, &a); err != nil { + return AssignerAsk{}, err + } + if err := a.check(key); err != nil { + return AssignerAsk{}, err + } + return a, nil +} + +// SameAssignerAsk says whether two asks are the same value. +func SameAssignerAsk(a, b AssignerAsk) bool { + return a.V == b.V && a.Asking == b.Asking && a.Ask.Digest() == b.Ask.Digest() +} + +// ViewAnswer is the view's answer to an ask: the id of the asking it answers (the ask's id, AskingID of its key and +// asking), the option the operator chose and their words. The entry's own time says when. type ViewAnswer struct { V int `json:"v"` + Asking string `json:"asking"` Option string `json:"option"` Words string `json:"words,omitempty"` } func (a ViewAnswer) check() error { + if strings.TrimSpace(a.Asking) == "" { + return fmt.Errorf("delivery: a view answer names no asking") + } if strings.TrimSpace(a.Option) == "" { return fmt.Errorf("delivery: a view answer names no option") } return nil } -// EncodeViewAnswer is a as JSON at Version; it refuses an answer naming no option. +// ViewAnswerFits says whether the view's answer a answers the ask as it stands now: it refuses an answer to another +// asking of the ask (a stale answer to an earlier writing of the same key) and an option the ask does not offer, +// naming both. +func ViewAnswerFits(a ViewAnswer, ask AssignerAsk) error { + if a.Asking != ask.Ask.ID { + return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, ask.Ask.ID) + } + if _, ok := ask.Ask.Option(a.Option); !ok { + return fmt.Errorf("delivery: the view's answer chose %q, which the asking %s does not offer", a.Option, ask.Ask.ID) + } + return nil +} + +// EncodeViewAnswer is a as JSON at Version; it refuses an answer naming no asking or no option. func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { if err := a.check(); err != nil { return nil, err @@ -168,7 +245,7 @@ func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { } // DecodeViewAnswer reads a view answer; it refuses an unknown version (ErrUnknownVersion) and an answer naming no -// option. +// asking or no option. func DecodeViewAnswer(data []byte) (ViewAnswer, error) { var a ViewAnswer if err := decode(data, &a); err != nil { diff --git a/go/delivery/view_answers_test.go b/go/delivery/view_answers_test.go index d3bca5d..c069e01 100644 --- a/go/delivery/view_answers_test.go +++ b/go/delivery/view_answers_test.go @@ -3,6 +3,9 @@ package delivery import ( "strings" "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" ) // 1297/B1c: the kinds of ask, and which need proof of who answered: the merge does, hold and restore do not, an @@ -73,9 +76,9 @@ func TestViewAnswerKey(t *testing.T) { } } -// 1297/B1c: a view answer round-trips; one naming no option is refused. +// 1297/B1c: a view answer round-trips; one naming no asking or no option is refused. func TestViewAnswerRoundTrip(t *testing.T) { - a := ViewAnswer{Option: "release", Words: "fixed by #12"} + a := ViewAnswer{Asking: "hold-mesh-mail-aaaa-0123456789ab", Option: "release", Words: "fixed by #12"} data, err := EncodeViewAnswer(a) if err != nil { t.Fatal(err) @@ -85,7 +88,74 @@ func TestViewAnswerRoundTrip(t *testing.T) { if err != nil || !SameViewAnswer(got, a) { t.Errorf("view answer came back %+v, %v", got, err) } - if _, err := EncodeViewAnswer(ViewAnswer{Option: " "}); err == nil { + if _, err := EncodeViewAnswer(ViewAnswer{Asking: "x", Option: " "}); err == nil { t.Errorf("a view answer naming no option was accepted") } + if _, err := EncodeViewAnswer(ViewAnswer{Option: "release"}); err == nil { + t.Errorf("a view answer naming no asking was accepted") + } +} + +func holdAsk(t *testing.T, asking int) (string, AssignerAsk) { + t.Helper() + key, err := HoldAskKey("mesh-mail", fp2of('f')) + if err != nil { + t.Fatal(err) + } + return key, AssignerAsk{Asking: asking, Ask: asks.Ask{ID: AskingID(key, asking), Headline: "mesh-mail is held", Explanation: "its health failed", + Options: []asks.Option{{ID: "release", Label: "Release"}, {ID: "keep", Label: "Keep held"}}, Who: "operator", + Expires: time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC), OnExpiry: "it stays held"}} +} + +// 1297/B1c: the assigner's ask round-trips at its key; an id another key or asking gives, an ask of a kind that needs +// proof and one with no option are refused. +func TestAssignerAskRoundTrip(t *testing.T) { + key, a := holdAsk(t, 2) + data, err := EncodeAssignerAsk(key, a) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(data), `{"v":1,"ask":`) { + t.Errorf("the ask is written as %s", data) + } + got, err := DecodeAssignerAsk(key, data) + a.V = Version + if err != nil || !SameAssignerAsk(got, a) { + t.Errorf("the ask came back %+v, %v", got, err) + } + other, _ := HoldAskKey("mesh-web", fp2of('f')) + if _, err := DecodeAssignerAsk(other, data); err == nil { + t.Errorf("an ask was read at another key") + } + stale := a + stale.Asking = 3 + if _, err := EncodeAssignerAsk(key, stale); err == nil { + t.Errorf("an ask whose id is another asking's was accepted") + } + merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) + if _, err := EncodeAssignerAsk(merge, AssignerAsk{Ask: asks.Ask{ID: AskingID(merge, 0), Options: a.Ask.Options}}); err == nil { + t.Errorf("the assigner was let write a merge's ask") + } + none := a + none.Ask.Options = nil + if _, err := EncodeAssignerAsk(key, none); err == nil { + t.Errorf("an ask with no option was accepted") + } +} + +// 1297/B1c: a view answer fits only the asking it names: an answer to an earlier asking of the same key is refused, +// naming both, and so is an option the ask does not offer. +func TestViewAnswerFits(t *testing.T) { + key, first := holdAsk(t, 0) + _, second := holdAsk(t, 2) + answer := ViewAnswer{Asking: AskingID(key, 0), Option: "release"} + if err := ViewAnswerFits(answer, first); err != nil { + t.Errorf("the answer to the first asking does not fit it: %v", err) + } + if err := ViewAnswerFits(answer, second); err == nil || !strings.Contains(err.Error(), first.Ask.ID) || !strings.Contains(err.Error(), second.Ask.ID) { + t.Errorf("a stale answer fits the later asking, or the refusal names neither: %v", err) + } + if err := ViewAnswerFits(ViewAnswer{Asking: first.Ask.ID, Option: "restore"}, first); err == nil { + t.Errorf("an option the ask does not offer fits") + } }