240 lines
8.6 KiB
Go
240 lines
8.6 KiB
Go
package view
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// 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, asks.Ask) {
|
|
t.Helper()
|
|
key, err := delivery.HoldAskKey("plex", fpNew)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := asks.Ask{ID: delivery.AskID(key), Headline: "plex is held after a failed health check", Explanation: "ace failed the check on the new build.", Who: asks.Operator,
|
|
Expires: t0.Add(23 * time.Hour), OnExpiry: "the fingerprint stays held",
|
|
Options: []asks.Option{{ID: "release", Label: "Release", Does: "lets the move go on", Level: asks.Approve}, {ID: "keep", Label: "Keep held", Does: "keeps it held", Level: asks.Approve}}}
|
|
v, err := delivery.EncodeAssignerAsk(key, delivery.AssignerAsk{Ask: a})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Entry{Bucket: delivery.AssignerAsksBucket, Key: key, Value: v, At: t0}, a
|
|
}
|
|
|
|
func TestAnAskNeedingNoProofIsOpenInTheViewWithItsOptions(t *testing.T) { // 1297/B9
|
|
m := New()
|
|
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(), &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 := delivery.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 := delivery.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: delivery.ViewAnswersBucket, 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)
|
|
merge := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges.")
|
|
cases := []struct {
|
|
name, key, option, want string
|
|
prep []Entry
|
|
}{
|
|
{"the merge's Approve", merge.Key, "approve", "answer it on the phone (issue 953)", []Entry{merge}},
|
|
{"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(), &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()
|
|
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)
|
|
}
|
|
merge := askEntry(t, "feat/x", "Merge", "Merges.")
|
|
fill(m, merge)
|
|
err := AnswerAsk(path, merge.Key, "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(), &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.
|
|
merge := askEntry(t, "feat/x", "Merge", "Merges.")
|
|
fill(m, merge)
|
|
rec = postAnswer(h, url.Values{"key": {merge.Key}, "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()
|
|
e, a := holdAsk(t)
|
|
fill(m, e)
|
|
stale, err := delivery.EncodeViewAnswer(delivery.ViewAnswer{Asking: "an-earlier-asking", Option: "release"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fill(m, Entry{Bucket: delivery.ViewAnswersBucket, 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 := delivery.DecodeViewAnswer(w.puts[e.Key+".view"]); err != nil || v.Asking != a.ID {
|
|
t.Errorf("answer = %+v, %v; want asking %s", v, err, a.ID)
|
|
}
|
|
}
|