Files
mesh-delivery/modules/mesh-delivery-view/internal/view/answers_test.go
T
jschoubben 0de534c692 Names N3 (view): the status view names its own buckets from its credential's module
Its states answers, reach and waits (viewstate WaitsBucket, ReachBucket, ViewAnswersBucket), its
first views and its marks were named from the constant ViewModule; they are now
`<cred.Module>_<state>` (viewstate.BucketsOf), and the model is made for its module.

Builds: design 47 G A-296

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 19:14:55 +01:00

250 lines
9.5 KiB
Go

package view
import (
"bufio"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
)
// memWriter is the view's bucket answers in memory.
type memWriter struct {
puts map[string][]byte
fail error
}
func (w *memWriter) Put(key string, value []byte) error {
if w.fail != nil {
return w.fail
}
if w.puts == nil {
w.puts = map[string][]byte{}
}
w.puts[key] = value
return nil
}
// holdAsk is a key of the assigner's asks: the ask about a held fingerprint, as a fixture (in release 5 the assigner writes
// none yet; the value is `{"v":1,"ask":<the router's ask>}`, as the gate's minus group and heads).
func holdAsk(t *testing.T) (Entry, oc.Ask) {
t.Helper()
key, err := asks.HoldAskKey("plex", fpNew)
if err != nil {
t.Fatal(err)
}
a := oc.Ask{ID: meshdeliveryclient.AskID(key), Headline: "plex is held after a failed health check", Explanation: "ace failed the check on the new build.", Who: occ.Operator,
Expires: occ.FormatTime(t0.Add(23 * time.Hour)), OnExpiry: "the fingerprint stays held",
Options: []oc.Option{{ID: "release", Label: "Release", Does: "lets the move go on", Level: occ.Approve}, {ID: "keep", Label: "Keep held", Does: "keeps it held", Level: occ.Approve}}}
v, err := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, a)})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: AssignerAsksBucket, Key: key, Value: v, At: t0}, a
}
func TestAnAskNeedingNoProofIsOpenInTheViewWithItsOptions(t *testing.T) { // 1297/B9
m := New(viewstate.ViewModule)
e, a := holdAsk(t)
fill(m, e)
got := m.Snapshot().Asks
if len(got) != 1 || got[0].Key != e.Key || got[0].Headline != a.Headline || got[0].How != AnswerInTheView || strings.Join(got[0].Options, ",") != "release,keep" {
t.Fatalf("asks = %+v", got)
}
// Once the teller filled it (its answer's warrant names the ask's id) it is answered and leaves the list.
fill(m, answerToAsking(t, e.Key, a.ID))
if got := m.Snapshot().Asks; len(got) != 0 {
t.Errorf("an answered ask is listed: %+v", got)
}
}
func TestTheViewWritesItsAnswerUnderTheAsksViewKey(t *testing.T) { // 1297/B9a AC
m, w := New(viewstate.ViewModule), &memWriter{}
m.SetWriter(w)
e, _ := holdAsk(t)
fill(m, e)
if err := m.Answer(e.Key, "release", "the check was a flake"); err != nil {
t.Fatal(err)
}
raw, ok := w.puts[e.Key+".view"]
if !ok || len(w.puts) != 1 {
t.Fatalf("puts = %v, want the one key %s.view", w.puts, e.Key)
}
a, err := viewstate.DecodeViewAnswer(raw)
if err != nil || a.Option != "release" || a.Words != "the check was a flake" {
t.Errorf("answer = %+v, %v", a, err)
}
// The key is the SDK's: the bucket's key parses back to the ask.
if back, err := viewstate.ParseViewAnswerKey(e.Key + ".view"); err != nil || back != e.Key {
t.Errorf("parse = %q, %v", back, err)
}
// The view hears its own answer back from its bucket and shows the ask as answered, waiting for the teller.
fill(m, Entry{Bucket: viewstate.BucketsOf(viewstate.ViewModule).Answers, Key: e.Key + ".view", Value: raw, At: t0})
got := m.Snapshot().Asks
if len(got) != 1 || got[0].Chosen != "release" {
t.Errorf("asks = %+v, want the ask with chosen release", got)
}
}
func TestTheViewRefusesWhatItMayNotAnswer(t *testing.T) { // 1297/B9a AC
e, a := holdAsk(t)
mergeKey, err := meshdeliveryclient.MergeAskKey("feat/x", []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}})
if err != nil {
t.Fatal(err)
}
cases := []struct {
name, key, option, want string
prep []Entry
}{
{"the merge's Approve", mergeKey, "approve", "answer it on the phone (issue 953)", nil},
{"an option the ask does not offer", e.Key, "delete", `offers no option "delete"`, []Entry{e}},
{"an ask the view does not hold", e.Key, "release", "no open ask", nil},
{"an ask that is no ask key", "hold.x", "release", "not an ask key", nil},
{"an ask the teller has filled", e.Key, "release", "no open ask", []Entry{e, answerToAsking(t, e.Key, a.ID)}},
{"no option", e.Key, " ", "no option", []Entry{e}},
}
for _, c := range cases {
m, w := New(viewstate.ViewModule), &memWriter{}
m.SetWriter(w)
fill(m, c.prep...)
err := m.Answer(c.key, c.option, "")
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: err = %v, want it to say %q", c.name, err, c.want)
}
if len(w.puts) != 0 {
t.Errorf("%s: wrote %v", c.name, w.puts)
}
}
m := New(viewstate.ViewModule)
e2, _ := holdAsk(t)
fill(m, e2)
if err := m.Answer(e2.Key, "release", ""); err == nil || !strings.Contains(err.Error(), "cannot write") {
t.Errorf("a view with no writer: %v", err)
}
m.SetWriter(&memWriter{fail: fmt.Errorf("the bus said no")})
if err := m.Answer(e2.Key, "release", ""); err == nil || !strings.Contains(err.Error(), "the bus said no") {
t.Errorf("a failed write: %v", err)
}
}
func TestTheSocketAnswersAnAsk(t *testing.T) { // 1297/B9b
path, m := served(t)
w := &memWriter{}
m.SetWriter(w)
e, _ := holdAsk(t)
fill(m, e)
if err := AnswerAsk(path, e.Key, "keep", "wait for the next build"); err != nil {
t.Fatal(err)
}
if _, ok := w.puts[e.Key+".view"]; !ok {
t.Fatalf("puts = %v", w.puts)
}
mergeKey, err := meshdeliveryclient.MergeAskKey("feat/x", []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}})
if err != nil {
t.Fatal(err)
}
err = AnswerAsk(path, mergeKey, "approve", "")
if err == nil || !strings.Contains(err.Error(), "answer it on the phone (issue 953)") {
t.Errorf("merge over the socket: %v", err)
}
// the old asks stay refused
c, err := dial(path)
if err != nil {
t.Fatal(err)
}
c.Write([]byte(`{"ask":"answer"}` + "\n"))
line, _ := bufio.NewReader(c).ReadString('\n')
c.Close()
if !strings.Contains(line, `"error"`) {
t.Errorf("an answer naming nothing: %q", line)
}
}
func postAnswer(h http.Handler, form url.Values, mod func(*http.Request)) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/answer", strings.NewReader(form.Encode()))
req.Host = "127.0.0.1:8471"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if mod != nil {
mod(req)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestThePageAnswersAnAskAndRefusesAForeignPost(t *testing.T) { // 1297/B9a
m, w := New(viewstate.ViewModule), &memWriter{}
m.SetWriter(w)
e, _ := holdAsk(t)
fill(m, e)
h := Web(m)
page := httptest.NewRecorder()
h.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/", nil))
for _, want := range []string{`action="/answer"`, `value="release"`, `value="keep"`, e.Key} {
if !strings.Contains(page.Body.String(), want) {
t.Errorf("the page lacks %q:\n%s", want, page.Body.String())
}
}
form := url.Values{"key": {e.Key}, "option": {"release"}, "words": {"ok"}}
// A post from another origin, or to a host name that is not the loopback's, is refused and writes nothing.
for name, mod := range map[string]func(*http.Request){
"another origin": func(r *http.Request) { r.Header.Set("Origin", "http://evil.example") },
"another host": func(r *http.Request) { r.Host = "evil.example" },
} {
if rec := postAnswer(h, form, mod); rec.Code != http.StatusForbidden || len(w.puts) != 0 {
t.Errorf("%s: %d, puts %v", name, rec.Code, w.puts)
}
}
rec := postAnswer(h, form, func(r *http.Request) { r.Header.Set("Origin", "http://127.0.0.1:8471") })
if rec.Code != http.StatusSeeOther || len(w.puts) != 1 {
t.Fatalf("answer = %d %q, puts %v", rec.Code, rec.Body.String(), w.puts)
}
// The merge's Approve is refused with its reason, and the rest of the page stays read-only.
mergeKey, err := meshdeliveryclient.MergeAskKey("feat/x", []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}})
if err != nil {
t.Fatal(err)
}
rec = postAnswer(h, url.Values{"key": {mergeKey}, "option": {"approve"}}, nil)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "answer it on the phone (issue 953)") || len(w.puts) != 1 {
t.Errorf("merge = %d %q", rec.Code, rec.Body.String())
}
if rec := httptest.NewRecorder(); true {
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/answer", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET /answer = %d", rec.Code)
}
}
}
func TestAStaleViewAnswerOfAnotherAskingIsNotTheChoice(t *testing.T) { // 1297/B9 (stale answer)
m := New(viewstate.ViewModule)
e, a := holdAsk(t)
fill(m, e)
stale, err := viewstate.EncodeViewAnswer(viewstate.ViewAnswer{Asking: "an-earlier-asking", Option: "release"})
if err != nil {
t.Fatal(err)
}
fill(m, Entry{Bucket: viewstate.BucketsOf(viewstate.ViewModule).Answers, Key: e.Key + ".view", Value: stale, At: t0})
if got := m.Snapshot().Asks; len(got) != 1 || got[0].Chosen != "" {
t.Errorf("a stale answer shows as the choice: %+v", got)
}
// and the view may answer this asking, writing its id
w := &memWriter{}
m.SetWriter(w)
if err := m.Answer(e.Key, "keep", ""); err != nil {
t.Fatal(err)
}
if v, err := viewstate.DecodeViewAnswer(w.puts[e.Key+".view"]); err != nil || v.Asking != a.ID {
t.Errorf("answer = %+v, %v; want asking %s", v, err, a.ID)
}
}