package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/oltp"
)
const mpPassword = "pw-unique-77d2b1e0"
type mirrorGet struct {
On bool `json:"on"`
Pushed string `json:"pushed"`
PushedAt string `json:"pushed_at"`
Error string `json:"error"`
ErrorAt string `json:"error_at"`
ErrorKind string `json:"error_kind"`
Fails int `json:"fails"`
NextTry string `json:"next_try"`
Created string `json:"created"`
Paused string `json:"paused"`
PausedBy string `json:"paused_by"`
}
func (g siteRig) mirrorGet(t *testing.T, kind string) mirrorGet {
t.Helper()
code, out := g.doAs("alice", http.MethodGet, "/api/v1/r/pub/"+kind+"-mirror", doorPassword, "", nil)
var m mirrorGet
if code != http.StatusOK || json.Unmarshal([]byte(out), &m) != nil {
t.Fatalf("FAIL: get %s mirror: %d %s", kind, code, out)
}
return m
}
func (g siteRig) settingsPage(t *testing.T, cookies []*http.Cookie, query string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/r/pub/settings"+query, nil)
req.SetBasicAuth("alice", doorPassword)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
g.h.ServeHTTP(rec, req)
return rec.Body.String()
}
func mirrorState(page, kind string) string {
i := strings.Index(page, `<section id="`+kind+`-mirror"`)
if i < 0 {
return ""
}
m := regexp.MustCompile(`data-hook="mirror-state" data-state="([a-z]+)"`).FindStringSubmatch(page[i:])
if m == nil {
return ""
}
return m[1]
}
// Both get ops report the failures in a row and the time of the next
// try, which is the runner's own schedule applied to the failure's
// time; the last good push survives a failure; a refused login is told
// as such; a paused mirror says by what.
func TestMirrorGetOpsReportFailuresAndTheRunnersOwnNextTry(t *testing.T) {
g := newSiteRig(t)
set := func(kind string, body map[string]string) {
t.Helper()
if code, out := g.doAs("alice", http.MethodPost, "/api/v1/r/pub/"+kind+"-mirror", doorPassword, "", body); code != http.StatusOK {
t.Fatalf("FAIL: set %s: %d %s", kind, code, out)
}
}
set("fossil", map[string]string{"remote": "https://fossil.example.org/pub", "user": "pusher", "password": mpPassword})
set("git", map[string]string{"remote": "[email protected]:alice/pub.git"})
if m := g.mirrorGet(t, "fossil"); m.Fails != 0 || m.NextTry != "" || m.Created == "" {
t.Errorf("FAIL: a mirror that has not failed: %+v", m)
}
// A good push, then failures: the push is kept beside them.
f, _, _ := g.db.FossilMirrorOf("pub")
if err := g.db.RecordFossilMirrorPush("pub", f.Gen, "tipfossil"); err != nil {
t.Fatal(err)
}
gm, _, _ := g.db.GitMirrorOf("pub")
if err := g.db.RecordGitMirrorPush("pub", gm.PublicKey, gm.Remote, gm.Branch, "tipgit"); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if err := g.db.RecordFossilMirrorFailure("pub", f.Gen, oltp.FossilLoginRefusedWords); err != nil {
t.Fatal(err)
}
}
if err := g.db.RecordGitMirrorFailure("pub", gm.PublicKey, gm.Remote, gm.Branch, "the host could not be reached"); err != nil {
t.Fatal(err)
}
fm, gtm := g.mirrorGet(t, "fossil"), g.mirrorGet(t, "git")
if fm.Fails != 3 || fm.Pushed != "tipfossil" || fm.PushedAt == "" || fm.Error == "" || fm.ErrorKind != "login" {
t.Errorf("FAIL: fossil after three failures: %+v", fm)
}
if gtm.Fails != 1 || gtm.Pushed != "tipgit" || gtm.PushedAt == "" || gtm.ErrorKind != "" {
t.Errorf("FAIL: git after a failure: %+v", gtm)
}
for kind, m := range map[string]mirrorGet{"fossil": fm, "git": gtm} {
failed, err := time.Parse(time.RFC3339, m.ErrorAt)
if err != nil {
t.Fatalf("FAIL: %s error_at %q", kind, m.ErrorAt)
}
want := failed.Add(oltp.MirrorBackoff(m.Fails))
next, err := time.Parse(time.RFC3339, m.NextTry)
if err != nil || !next.Equal(want) {
t.Errorf("FAIL: %s next try %q is not the failure's time plus the runner's backoff %v", kind, m.NextTry, want)
}
// The runner's own decision flips exactly there.
if kind == "fossil" {
if !fossilMirrorBackingOff(m.ErrorAt, m.Fails, next.Add(-time.Second)) || fossilMirrorBackingOff(m.ErrorAt, m.Fails, next) {
t.Errorf("FAIL: the fossil runner does not retry when the page says")
}
}
}
// The words the runner stores for a refused login are the ones the
// API classes as a login.
if fossilMirrorWords("zit push: the peer refused: login failed", "f.example.org") != oltp.FossilLoginRefusedWords {
t.Error("FAIL: the runner's login words are not the shared ones")
}
// The page reads them back: failing, and the last good push still there.
page := g.settingsPage(t, nil, "")
if mirrorState(page, "fossil") != "failing" || mirrorState(page, "git") != "failing" ||
!strings.Contains(page, "/checkin/tipfossil") || !strings.Contains(page, "third failure in a row") ||
!strings.Contains(page, `id="fossil-mirror-password"`) {
t.Error("FAIL: the page does not show both mirrors failing with their last good push and the password row open")
}
if strings.Contains(page, mpPassword) {
t.Error("FAIL: the password is on the page")
}
// Paused by a private repository.
priv := oltp.Private
if _, err := g.db.PatchRepo("pub", oltp.RepoPatch{Visibility: &priv}, "alice", ""); err != nil {
t.Fatal(err)
}
if m := g.mirrorGet(t, "fossil"); m.PausedBy != "private" || m.NextTry != "" || m.Fails != 3 {
t.Errorf("FAIL: a paused mirror reports a next try: %+v", m)
}
if m := g.mirrorGet(t, "git"); m.PausedBy != "private" || m.Paused == "" || m.NextTry != "" {
t.Errorf("FAIL: paused by: %+v", m)
}
if page := g.settingsPage(t, nil, ""); mirrorState(page, "fossil") != "paused" || mirrorState(page, "git") != "paused" ||
strings.Contains(page, `data-hook="mirror-edit"`) {
t.Error("FAIL: a private repository's mirrors are not paused and read-only")
}
t.Log("ok")
}
// A refused save through the real form names the field it is about, for
// each field of each mirror, and the typed remote and user come back
// while the password does not.
func TestRefusedMirrorSaveNamesItsFieldThroughTheForm(t *testing.T) {
g := newSiteRig(t)
post := func(kind string, form url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/r/pub/settings/"+kind+"-mirror", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("alice", doorPassword)
w := httptest.NewRecorder()
g.h.ServeHTTP(w, req)
return w
}
for name, tc := range map[string]struct {
kind string
form url.Values
field string
typed []string
}{
"fossil remote": {"fossil", url.Values{"remote": {"http://nope.example.org/x"}, "user": {"typed-user-31"}, "password": {mpPassword}}, "remote", []string{"typed-user-31"}},
"fossil user": {"fossil", url.Values{"remote": {"https://fossil.example.org/x"}, "user": {"bad user"}, "password": {mpPassword}}, "user", []string{"fossil.example.org/x"}},
"fossil login": {"fossil", url.Values{"remote": {"https://fossil.example.org/x"}, "user": {"typed-user-31"}}, "password", []string{"typed-user-31", "fossil.example.org/x"}},
"git remote": {"git", url.Values{"remote": {"https://github.com/o/r.git"}, "branch": {"typed-branch"}}, "remote", []string{"typed-branch"}},
"git branch": {"git", url.Values{"remote": {"[email protected]:o/r.git"}, "branch": {"-bad"}}, "branch", []string{"[email protected]:o/r.git"}},
} {
w := post(tc.kind, tc.form)
loc := w.Header().Get("Location")
if w.Code != http.StatusSeeOther || strings.Contains(loc, "?") {
t.Errorf("FAIL: %s: %d %s", name, w.Code, loc)
}
page := g.settingsPage(t, w.Result().Cookies(), "")
sec := page[strings.Index(page, `<section id="`+tc.kind+`-mirror"`):]
sec = sec[:strings.Index(sec, "</section>")]
if !strings.Contains(sec, `data-hook="mirror-field-error" data-field="`+tc.field+`"`) ||
!strings.Contains(sec, `id="`+tc.kind+`-mirror-`+tc.field+`-err"`) || strings.Count(sec, `aria-invalid="true"`) != 1 {
t.Errorf("FAIL: %s: the field %s is not the one marked", name, tc.field)
}
for _, typed := range tc.typed {
if !strings.Contains(sec, typed) {
t.Errorf("FAIL: %s: %q did not survive the refusal", name, typed)
}
}
if strings.Contains(page, mpPassword) || strings.Contains(strings.Join(w.Header().Values("Set-Cookie"), " "), mpPassword) {
t.Errorf("FAIL: %s: the password came back", name)
}
}
t.Log("ok")
}
// A save that fails on the edit form of a mirror that is on reopens that
// edit with the typed values, and Edit and Replace are chosen by the
// address alone.
func TestEditAndReplaceAreChosenByTheAddress(t *testing.T) {
g := newSiteRig(t)
if code, out := g.doAs("alice", http.MethodPost, "/api/v1/r/pub/fossil-mirror", doorPassword, "", map[string]string{
"remote": "https://fossil.example.org/pub", "user": "pusher", "password": mpPassword}); code != http.StatusOK {
t.Fatalf("FAIL: %d %s", code, out)
}
rest := g.settingsPage(t, nil, "")
if mirrorState(rest, "fossil") != "pushing" || !strings.Contains(rest, `data-hook="mirror-edit"`) || strings.Contains(rest, `id="fossil-mirror-remote"`) {
t.Error("FAIL: the saved, never pushed mirror at rest")
}
if edit := g.settingsPage(t, nil, "?edit=fossil-mirror"); !strings.Contains(edit, `id="fossil-mirror-remote"`) || !strings.Contains(edit, `id="fossil-mirror-user"`) {
t.Error("FAIL: ?edit= did not open the remote and user")
}
if rep := g.settingsPage(t, nil, "?edit=fossil-mirror-password"); strings.Contains(rep, `id="fossil-mirror-remote"`) || !strings.Contains(rep, `data-hook="mirror-save-password"`) {
t.Error("FAIL: ?edit= did not open the password row alone")
}
if junk := g.settingsPage(t, nil, "?edit=anything"); strings.Contains(junk, `id="fossil-mirror-remote"`) {
t.Error("FAIL: an unknown edit opened something")
}
// A new remote with no password is refused on the password, and the
// edit it came from is open again with the typed remote.
req := httptest.NewRequest(http.MethodPost, "/r/pub/settings/fossil-mirror",
strings.NewReader(url.Values{"do": {"set"}, "edit": {"all"}, "remote": {"https://other.example.org/pub"}, "user": {"pusher"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("alice", doorPassword)
w := httptest.NewRecorder()
g.h.ServeHTTP(w, req)
page := g.settingsPage(t, w.Result().Cookies(), "")
if !strings.Contains(page, `value="https://other.example.org/pub"`) || !strings.Contains(page, `data-field="password"`) {
t.Error("FAIL: the refused edit did not reopen with the typed remote and the password marked")
}
if m := g.mirrorGet(t, "fossil"); !strings.Contains(m.Created, "20") {
t.Errorf("FAIL: created is not reported: %+v", m)
}
t.Log("ok")
}
// Through the API door a password alone replaces the password of a mirror
// that is on and leaves its remote and user, and is refused when the
// mirror is off or the repository is private.
func TestPasswordOnlyThroughTheDoors(t *testing.T) {
g := newSiteRig(t)
set := "/api/v1/r/pub/fossil-mirror"
if code, out := g.doAs("alice", http.MethodPost, set, doorPassword, "", map[string]string{"password": mpPassword}); code != http.StatusBadRequest {
t.Errorf("FAIL: a password alone on a mirror that is off: %d %s", code, out)
}
if code, out := g.doAs("alice", http.MethodPost, set, doorPassword, "", map[string]string{
"remote": "https://fossil.example.org/pub", "user": "pusher", "password": "first-pw"}); code != http.StatusOK {
t.Fatalf("FAIL: %d %s", code, out)
}
before, _, _ := g.db.FossilMirrorOf("pub")
code, out := g.doAs("alice", http.MethodPost, set, doorPassword, "", map[string]string{"password": mpPassword})
if code != http.StatusOK || strings.Contains(out, mpPassword) {
t.Fatalf("FAIL: password only: %d %s", code, out)
}
after, _, _ := g.db.FossilMirrorOf("pub")
if after.Remote != before.Remote || after.User != before.User || after.Gen == before.Gen {
t.Errorf("FAIL: remote, user or generation: %+v -> %+v", before, after)
}
if got, _ := g.db.FossilMirrorPassword("pub", after.Gen); got != mpPassword {
t.Error("FAIL: the password was not replaced")
}
priv := oltp.Private
if _, err := g.db.PatchRepo("pub", oltp.RepoPatch{Visibility: &priv}, "alice", ""); err != nil {
t.Fatal(err)
}
if code, out := g.doAs("alice", http.MethodPost, set, doorPassword, "", map[string]string{"password": "x-other"}); code == http.StatusOK {
t.Errorf("FAIL: password only on a private repository: %s", out)
}
t.Log("ok")
}