package web
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/ops"
)
const (
mpRemote = "https://fossil.example.org/alpha"
mpGitRem = "[email protected]:owner/alpha.git"
mpKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHrrEHwlXufiSa quarry alpha"
mpTip = "9075824f99aa11bb22cc33dd"
mpTime = "2026-10-01T16:00:00Z"
mpFailing = "the host could not be reached"
)
func mpRender(t *testing.T, kind string, p mirrorPanel, d pageData) string {
t.Helper()
p.Kind = kind
d.Repo, d.Caps, d.RepoAdmin = "alpha", &capsPanel{}, true
if kind == "git" {
d.GitMirror = &p
} else {
d.FossilMirror = &p
}
return renderSettings(t, d)
}
// section is the HTML of one mirror section, so a hook in the other is
// not mistaken for this one's.
func mpSection(t *testing.T, body, kind string) string {
t.Helper()
start := strings.Index(body, `<section id="`+kind+`-mirror"`)
if start < 0 {
t.Fatalf("FAIL: no %s mirror section", kind)
}
end := strings.Index(body[start:], "</section>")
return body[start : start+end]
}
func mpHas(s, hook string) bool { return strings.Contains(s, `data-hook="`+hook+`"`) }
func mpState(s string) string {
m := regexp.MustCompile(`data-hook="mirror-state" data-state="([a-z]+)"`).FindStringSubmatch(s)
if m == nil {
return ""
}
return m[1]
}
// panelsFor is one panel per state of the section, for either kind.
func mpPanels(kind string) map[string]mirrorPanel {
on := mirrorPanel{On: true, Remote: mpRemote, User: "pusher", Created: mpTime}
if kind == "git" {
on = mirrorPanel{On: true, Remote: mpGitRem, Branch: "main", PublicKey: mpKey, Created: mpTime}
}
with := func(f func(*mirrorPanel)) mirrorPanel { p := on; f(&p); return p }
return map[string]mirrorPanel{
"off": {},
"pushing": on,
"ok": with(func(p *mirrorPanel) { p.Pushed, p.PushedAt = mpTip, mpTime }),
"failing": with(func(p *mirrorPanel) {
p.Pushed, p.PushedAt = mpTip, mpTime
p.LastErr, p.LastErrAt, p.Fails, p.NextTry = mpFailing, mpTime, 3, time.Now().Add(8*time.Minute).UTC().Format(time.RFC3339)
}),
"paused": with(func(p *mirrorPanel) {
p.Pushed, p.PushedAt = mpTip, mpTime
p.Paused, p.PausedBy = "the repository is private, so the mirror is paused", "private"
}),
}
}
// The heading carries the state under one hook, in each of the five
// states, for each mirror.
func TestMirrorHeadingCarriesTheState(t *testing.T) {
for _, kind := range []string{"git", "fossil"} {
for want, p := range mpPanels(kind) {
s := mpSection(t, mpRender(t, kind, p, pageData{}), kind)
if got := mpState(s); got != want {
t.Errorf("FAIL: %s mirror %s: state hook says %q", kind, want, got)
}
if !mpHas(s, "mirror-facts") {
t.Errorf("FAIL: %s mirror %s: no facts list", kind, want)
}
if dot := strings.Contains(s, `class="mst none"`); dot != (want == "off") {
t.Errorf("FAIL: %s mirror %s: only Off is a state without a dot", kind, want)
}
}
}
t.Log("ok")
}
// Each state reads as its own rows, and the rows that do not belong to
// it are absent.
func TestMirrorFactsRowsPerState(t *testing.T) {
type rows map[string]bool
want := map[string]rows{
"off": {"mirror-last-push": false, "mirror-next-push": false, "mirror-last-try": false, "mirror-next-try": false},
"pushing": {"mirror-last-push": true, "mirror-next-push": true, "mirror-last-try": false, "mirror-next-try": false},
"ok": {"mirror-last-push": true, "mirror-next-push": true, "mirror-last-try": false, "mirror-next-try": false},
"failing": {"mirror-last-push": true, "mirror-next-push": false, "mirror-last-try": true, "mirror-next-try": true},
"paused": {"mirror-last-push": true, "mirror-next-push": true, "mirror-last-try": false, "mirror-next-try": false},
}
for _, kind := range []string{"git", "fossil"} {
for state, p := range mpPanels(kind) {
s := mpSection(t, mpRender(t, kind, p, pageData{}), kind)
for hook, present := range want[state] {
if mpHas(s, hook) != present {
t.Errorf("FAIL: %s mirror %s: %s present=%v, want %v", kind, state, hook, !present, present)
}
}
// The key and the password row belong to their kind and to On.
if kind == "git" && mpHas(s, "git-mirror-key") != (state != "off") {
t.Errorf("FAIL: git mirror %s: deploy key row", state)
}
if kind == "fossil" && mpHas(s, "mirror-password-saved") != (state != "off") {
t.Errorf("FAIL: fossil mirror %s: saved-password row", state)
}
}
}
t.Log("ok")
}
// A failing mirror keeps its last good push on the page beside the
// failure, with a link to the check-in, and says which failure this is.
func TestFailingMirrorKeepsTheLastGoodPush(t *testing.T) {
for _, kind := range []string{"git", "fossil"} {
s := mpSection(t, mpRender(t, kind, mpPanels(kind)["failing"], pageData{}), kind)
if !strings.Contains(s, "/checkin/"+mpTip) || !mpHas(s, "mirror-last-push") || !mpHas(s, "mirror-last-try") {
t.Errorf("FAIL: %s mirror: a failure replaced the last good push", kind)
}
if !strings.Contains(s, "third failure in a row") || !mpHas(s, "mirror-next-try") {
t.Errorf("FAIL: %s mirror: the next try does not say which failure this is", kind)
}
}
t.Log("ok")
}
// No state of either section ever writes a value into a password input,
// and the page has nowhere to put one.
func TestMirrorPageNeverRendersAPassword(t *testing.T) {
pw := regexp.MustCompile(`<input[^>]*type="password"[^>]*>`)
for state, p := range mpPanels("fossil") {
for _, edit := range []string{"", "all", "password"} {
q := p
q.Edit = edit
if !q.On {
q.Edit = ""
}
s := mpSection(t, mpRender(t, "fossil", q, pageData{}), "fossil")
for _, tag := range pw.FindAllString(s, -1) {
if strings.Contains(tag, "value=") {
t.Errorf("FAIL: %s/%q: a password input carries a value: %s", state, edit, tag)
}
}
}
}
// A login the server refused turns the row into an input already.
f := mpPanels("fossil")["failing"]
f.LastErr, f.ErrKind = "the server refused the login", "login"
s := mpSection(t, mpRender(t, "fossil", f, pageData{}), "fossil")
if len(pw.FindAllString(s, -1)) != 1 || mpHas(s, "mirror-password-saved") || mpHas(s, "mirror-cancel") {
t.Error("FAIL: a refused login does not open the password row on its own")
}
t.Log("ok")
}
// Edit opens the remote and the user or branch as inputs and brings the
// password back only to be left blank; Replace opens the password row
// alone and posts the saved remote and user with it.
func TestMirrorEditAndReplaceStates(t *testing.T) {
ok := mpPanels("fossil")["ok"]
read := mpSection(t, mpRender(t, "fossil", ok, pageData{}), "fossil")
if !mpHas(read, "mirror-edit") || !mpHas(read, "mirror-replace") {
t.Error("FAIL: the resting mirror lacks Edit or Replace")
}
if strings.Contains(read, `id="fossil-mirror-remote"`) {
t.Error("FAIL: the resting mirror shows its remote as an input")
}
ok.Edit = "all"
all := mpSection(t, mpRender(t, "fossil", ok, pageData{}), "fossil")
for _, id := range []string{`id="fossil-mirror-remote"`, `id="fossil-mirror-user"`, `id="fossil-mirror-password"`} {
if !strings.Contains(all, id) {
t.Errorf("FAIL: Edit did not open %s", id)
}
}
if !mpHas(all, "mirror-save") || !mpHas(all, "mirror-cancel") || mpHas(all, "mirror-edit") || !mpHas(all, "fossil-mirror-keeps") {
t.Error("FAIL: Edit's buttons")
}
if !strings.Contains(all, `name="edit" value="all"`) {
t.Error("FAIL: the edit form does not say where a refusal reopens")
}
if pw := inputTag(all, "password"); strings.Contains(pw, "required") {
t.Errorf("FAIL: a saved password is required again on Edit: %s", pw)
}
ok.Edit = "password"
rep := mpSection(t, mpRender(t, "fossil", ok, pageData{}), "fossil")
if strings.Contains(rep, `id="fossil-mirror-remote"`) || !strings.Contains(rep, `id="fossil-mirror-password"`) ||
!mpHas(rep, "mirror-save-password") || !mpHas(rep, "mirror-cancel") {
t.Error("FAIL: Replace opens more than the password row, or lacks its buttons")
}
// The password-only form posts neither the remote nor the user, in
// any spelling: the op keeps what is stored.
if strings.Contains(rep, `name="remote"`) || strings.Contains(rep, `name="user"`) {
t.Error("FAIL: Replace posts a remote or a user")
}
g := mpPanels("git")["ok"]
g.Edit = "all"
gs := mpSection(t, mpRender(t, "git", g, pageData{}), "git")
if !strings.Contains(gs, `id="git-mirror-remote"`) || !strings.Contains(gs, `id="git-mirror-branch"`) || !mpHas(gs, "git-mirror-key") {
t.Error("FAIL: Git Edit does not open remote and branch beside the key")
}
t.Log("ok")
}
// Paused says why and what un-pauses it, shows its fields read-only and
// offers no Edit or Replace, because every save is refused while paused.
func TestPausedMirrorIsReadOnlyAndSaysWhy(t *testing.T) {
for _, kind := range []string{"git", "fossil"} {
p := mpPanels(kind)["paused"]
p.Edit = "all"
s := mpSection(t, mpRender(t, kind, p, pageData{}), kind)
if !mpHas(s, kind+"-mirror-paused") || !strings.Contains(s, `href="#visibility"`) {
t.Errorf("FAIL: %s paused (private) does not say what un-pauses it", kind)
}
if mpHas(s, "mirror-edit") || mpHas(s, "mirror-replace") || strings.Contains(s, `id="`+kind+`-mirror-remote"`) {
t.Errorf("FAIL: %s paused offers an edit", kind)
}
if !strings.Contains(s, `value="off"`) {
t.Errorf("FAIL: %s paused offers no way to stop", kind)
}
p.PausedBy, p.Paused = "takedown", "the repository is taken down, so the mirror is paused"
s = mpSection(t, mpRender(t, kind, p, pageData{}), kind)
if !mpHas(s, kind+"-mirror-paused") || strings.Contains(s, `href="#visibility"`) || mpState(s) != "paused" {
t.Errorf("FAIL: %s paused (takedown)", kind)
}
}
t.Log("ok")
}
// A refused save names its field, keeps the typed remote and user and
// never a password, and a refusal about another field marks nothing.
func TestRefusedSaveNamesItsField(t *testing.T) {
p := mirrorPanel{Kind: "fossil"}
p.refuse(&mirrorRefusal{Kind: "fossil", Field: "remote", Words: "the address has a user name or password in it",
Remote: "https://fossil.example.org/x", User: "typed-user"})
s := mpSection(t, mpRender(t, "fossil", p, pageData{}), "fossil")
input := inputTag(s, "remote")
if !strings.Contains(input, `aria-invalid="true"`) || !strings.Contains(input, `aria-describedby="fossil-mirror-remote-err"`) ||
!strings.Contains(input, `value="https://fossil.example.org/x"`) {
t.Errorf("FAIL: the remote input: %s", input)
}
if !strings.Contains(s, `id="fossil-mirror-remote-err"`) || !strings.Contains(s, `class="fld-err"`) ||
!strings.Contains(inputTag(s, "user"), `value="typed-user"`) || strings.Contains(inputTag(s, "user"), "aria-invalid") {
t.Error("FAIL: the error is not under its field, or the typed user did not survive")
}
if strings.Count(s, `data-hook="mirror-field-error"`) != 1 {
t.Error("FAIL: more than one field is marked")
}
if pw := inputTag(s, "password"); strings.Contains(pw, "value=") || strings.Contains(pw, "aria-invalid") {
t.Errorf("FAIL: the password input after a refusal: %s", pw)
}
// A refusal that belongs to the Git section never reaches this one.
other := mirrorPanel{Kind: "fossil"}
other.refuse(&mirrorRefusal{Kind: "git", Field: "remote", Words: "x"})
if other.Refusal != nil {
t.Error("FAIL: a Git refusal marked the Fossil section")
}
// A refusal about no field is the section's own line, not a field's.
g := mirrorPanel{Kind: "git"}
g.refuse(&mirrorRefusal{Kind: "git", Words: "a private repository cannot be mirrored"})
gs := mpSection(t, mpRender(t, "git", g, pageData{}), "git")
if !mpHas(gs, "git-mirror-refused") || strings.Contains(gs, `data-hook="mirror-field-error"`) {
t.Error("FAIL: a refusal about no field")
}
// On a mirror that is on, the refusal reopens the edit it came from
// with what was typed.
on := mirrorPanel{Kind: "fossil", On: true, Remote: mpRemote, User: "pusher", PushedAt: mpTime}
on.refuse(&mirrorRefusal{Kind: "fossil", Field: "password", Words: "give the login's password", Remote: "https://new.example.org/x", User: "pusher", Edit: "all"})
os := mpSection(t, mpRender(t, "fossil", on, pageData{}), "fossil")
if !strings.Contains(inputTag(os, "remote"), `value="https://new.example.org/x"`) ||
!strings.Contains(inputTag(os, "password"), `aria-describedby="fossil-mirror-password-err"`) {
t.Error("FAIL: a refused edit did not reopen with the typed values and the password marked")
}
t.Log("ok")
}
// The flash that carries a refusal names its field and the typed
// values, and holds no password, no credential from a remote and no
// remote that carried one.
func TestMirrorRefusalFlashHoldsNoSecret(t *testing.T) {
const user, pass, typed = "scrub-user-71", "scrub-pass-93", "typed-pw-5521"
remote := "https://" + user + ":" + pass + "@fossil.example.org/x"
err := &ops.Refusal{Code: "mirror_refused", Message: "could not reach " + remote + " with " + typed,
Fields: map[string]string{"field": "remote"}}
form := url.Values{"remote": {remote}, "user": {"pusher"}, "password": {typed}, "edit": {"all"}}
w := httptest.NewRecorder()
flashMirrorRefusal(w, httptest.NewRequest(http.MethodPost, "/", nil), "fossil", err, form)
cookie := strings.Join(w.Header().Values("Set-Cookie"), " ")
for _, secret := range []string{user, pass, typed} {
if strings.Contains(cookie, secret) {
t.Errorf("FAIL: the flash carries %q: %s", secret, cookie)
}
}
ref, rest := takeMirrorRefusal(strings.TrimPrefix(strings.SplitN(cookie, ";", 2)[0], flashCookie+"="))
if ref == nil || rest != "" || ref.Field != "remote" || ref.Kind != "fossil" || ref.User != "pusher" || ref.Edit != "all" || ref.Remote != "" {
t.Errorf("FAIL: the refusal after the flash: %+v rest %q", ref, rest)
}
// An address with nothing secret in it comes back as typed; a
// field the page does not know is no field.
form["remote"] = []string{"https://fossil.example.org/x"}
w = httptest.NewRecorder()
flashMirrorRefusal(w, httptest.NewRequest(http.MethodPost, "/", nil), "fossil",
&ops.Refusal{Message: "no", Fields: map[string]string{"field": "other"}}, form)
cookie = strings.Join(w.Header().Values("Set-Cookie"), " ")
ref, _ = takeMirrorRefusal(strings.TrimPrefix(strings.SplitN(cookie, ";", 2)[0], flashCookie+"="))
if ref == nil || ref.Remote != "https://fossil.example.org/x" || ref.Field != "" {
t.Errorf("FAIL: a plain refusal: %+v", ref)
}
// Git: a password in the address, scp-style or as a URL, is in the
// cookie nowhere, and not in the words.
for _, remote := range []string{"user:[email protected]:o/r.git", "ssh://user:[email protected]/o/r.git", "https://user:[email protected]/o/r.git"} {
w = httptest.NewRecorder()
flashMirrorRefusal(w, httptest.NewRequest(http.MethodPost, "/", nil), "git",
&ops.Refusal{Message: "could not use " + remote, Fields: map[string]string{"field": "remote"}},
url.Values{"remote": {remote}, "branch": {"main"}})
cookie = strings.Join(w.Header().Values("Set-Cookie"), " ")
ref, _ = takeMirrorRefusal(strings.TrimPrefix(strings.SplitN(cookie, ";", 2)[0], flashCookie+"="))
if strings.Contains(cookie, "s3cret-g1") || ref == nil || ref.Remote != "" {
t.Errorf("FAIL: git remote %q leaked or was echoed: %s", remote, cookie)
}
g := mirrorPanel{Kind: "git"}
g.refuse(ref)
if strings.Contains(mpSection(t, mpRender(t, "git", g, pageData{}), "git"), "s3cret-g1") {
t.Errorf("FAIL: git remote %q is on the page", remote)
}
}
// Another panel's flash is left alone.
if ref, rest := takeMirrorRefusal("git-mirror"); ref != nil || rest != "git-mirror" {
t.Error("FAIL: a saved-panel flash was taken for a refusal")
}
t.Log("ok")
}
func TestNextTryWordsAndOrdinals(t *testing.T) {
now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
for _, c := range []struct {
d time.Duration
want string
}{{-time.Hour, "Within a minute"}, {30 * time.Second, "Within a minute"}, {8 * time.Minute, "In 8 minutes"},
{61 * time.Second, "In 2 minutes"}, {time.Hour, "In 1 hour"}, {70 * time.Minute, "In 2 hours"}} {
if got := untilWords(now.Add(c.d), now); got != c.want {
t.Errorf("FAIL: %v: %q, want %q", c.d, got, c.want)
}
}
for n, want := range map[int]string{1: "first", 3: "third", 10: "tenth", 11: "11th"} {
if got := ordinal(n); got != want {
t.Errorf("FAIL: ordinal(%d) = %q", n, got)
}
}
if (&mirrorPanel{Fails: 1}).InARow() != "This was the first failure." || (&mirrorPanel{}).InARow() != "" {
t.Error("FAIL: the first failure is not a row")
}
var _ = errors.New
var _ = fmt.Sprint
t.Log("ok")
}
// The off form offers its set-up fields, and a private repository gets
// the sentence and no form.
func TestOffMirrorOffersTheSetUpForm(t *testing.T) {
for _, kind := range []string{"git", "fossil"} {
s := mpSection(t, mpRender(t, kind, mirrorPanel{}, pageData{}), kind)
if !mpHas(s, kind+"-mirror-form") || !strings.Contains(s, `class="btn-primary"`) || strings.Contains(s, `value="off"`) {
t.Errorf("FAIL: the %s set-up form", kind)
}
if p := mpRender(t, kind, mirrorPanel{}, pageData{RepoPrivate: true}); !mpHas(mpSection(t, p, kind), kind+"-mirror-private") ||
mpHas(mpSection(t, p, kind), kind+"-mirror-form") {
t.Errorf("FAIL: a private repository offers a %s form", kind)
}
}
if pw := inputTag(mpSection(t, mpRender(t, "fossil", mirrorPanel{}, pageData{}), "fossil"), "password"); !strings.Contains(pw, "required") {
t.Errorf("FAIL: the first-enable password is not required: %s", pw)
}
t.Log("ok")
}
// Only a repository admin sees the sections, and one that follows Git
// offers neither.
func TestMirrorSectionsAreGated(t *testing.T) {
d := pageData{Repo: "alpha", Caps: &capsPanel{}, GitMirror: &mirrorPanel{Kind: "git"}, FossilMirror: &mirrorPanel{Kind: "fossil"}}
if body := renderSettings(t, d); strings.Contains(body, `data-hook="fossil-mirror"`) || strings.Contains(body, `data-hook="git-mirror"`) {
t.Error("FAIL: a viewer without a sees a mirror section")
}
d.RepoAdmin = true
d.Posture.Mirror.Tracking, d.Posture.Mirror.Git = true, true
if body := renderSettings(t, d); strings.Contains(body, `data-hook="fossil-mirror"`) || strings.Contains(body, `data-hook="git-mirror"`) {
t.Error("FAIL: a repository that follows Git offers a mirror out")
}
t.Log("ok")
}
// A forged flash cannot put its own remote into the Replace form, and a
// refusal shows typed values only where an input holds them.
func TestRefusalShowsTypedValuesOnlyWhereThereIsAnInput(t *testing.T) {
ok := mpPanels("fossil")["ok"]
ok.refuse(&mirrorRefusal{Kind: "fossil", Field: "password", Words: "x", Edit: "password", Remote: "https://evil.example.org/x", User: "evil"})
s := mpSection(t, mpRender(t, "fossil", ok, pageData{}), "fossil")
if strings.Contains(s, "evil") || strings.Contains(s, `name="remote"`) || strings.Contains(s, `name="user"`) {
t.Error("FAIL: a forged flash reached the Replace form or the rows")
}
if !strings.Contains(s, mpRemote) {
t.Error("FAIL: the saved remote is not shown")
}
// A refused stop, which posts no remote, leaves the rows alone.
g := mpPanels("git")["ok"]
g.refuse(&mirrorRefusal{Kind: "git", Words: "refused"})
gs := mpSection(t, mpRender(t, "git", g, pageData{}), "git")
if !strings.Contains(gs, mpGitRem) || !strings.Contains(gs, "<code>main</code>") {
t.Error("FAIL: a refused stop blanked the rows")
}
// Paused: typed values are not shown as saved.
p := mpPanels("fossil")["paused"]
p.refuse(&mirrorRefusal{Kind: "fossil", Field: "remote", Words: "x", Edit: "all", Remote: "https://typed.example.org/x", User: "typed"})
ps := mpSection(t, mpRender(t, "fossil", p, pageData{}), "fossil")
if strings.Contains(ps, "typed.example.org") || !strings.Contains(ps, mpRemote) {
t.Error("FAIL: a paused mirror shows unsaved values as saved")
}
t.Log("ok")
}
// The Last try row says what went wrong, then what to do, then when, in
// its own element.
func TestLastTryRowOrder(t *testing.T) {
p := mpPanels("git")["failing"]
p.LastErr = "the remote's main has history this repository does not: it was not pushed"
s := mpSection(t, mpRender(t, "git", p, pageData{}), "git")
row := s[strings.Index(s, `data-hook="mirror-last-try"`):]
row = row[:strings.Index(row, "</dd>")]
i, j := strings.Index(row, "ci-fail-text"), strings.Index(row, `data-hook="mirror-last-try-at"`)
if i < 0 || j < i || strings.Contains(row[i:j], "<time") || !strings.Contains(row[j:], "<time") {
t.Errorf("FAIL: the Last try row: %s", row)
}
t.Log("ok")
}
// A typed value longer than its input is dropped, so the cookie stays
// small and the refusal still shows.
func TestRefusalFlashDropsOversizeValues(t *testing.T) {
long := "https://fossil.example.org/" + strings.Repeat("a", 4096)
form := url.Values{"remote": {long}, "user": {strings.Repeat("u", 100)}, "branch": {strings.Repeat("b", 200)}}
w := httptest.NewRecorder()
flashMirrorRefusal(w, httptest.NewRequest(http.MethodPost, "/", nil), "git",
&ops.Refusal{Message: "no", Fields: map[string]string{"field": "remote"}}, form)
cookie := strings.Join(w.Header().Values("Set-Cookie"), " ")
if len(cookie) > 1000 {
t.Errorf("FAIL: the cookie is %d bytes", len(cookie))
}
ref, _ := takeMirrorRefusal(strings.TrimPrefix(strings.SplitN(cookie, ";", 2)[0], flashCookie+"="))
if ref == nil || ref.Field != "remote" || ref.Remote != "" || ref.User != "" || ref.Branch != "" {
t.Errorf("FAIL: the refusal after oversize values: %+v", ref)
}
t.Log("ok")
}