// navci_test.go — the CI tab, and the page behind it when there is no CI.
package web
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ops"
)
// The tab follows CI, not runners, and not the letter alone.
//
// A repository with runs shows it to anyone who may see CI. A
// repository with NONE shows it to whoever could set CI up — hiding it
// from that person helps nobody — and to nobody else. An admin who says
// the repository does not use CI ends the offer for everyone.
//
// Runs, never runners: quarry's own CI is served by a worker on no group
// at all (471 runs when this was written), so a rule keyed on "has a
// runner of its own" would hide the tab of the one repository here that
// runs CI.
func TestCITabFollowsCIAndWhoCouldSetItUp(t *testing.T) {
has := func(d pageData) bool {
t.Helper()
var buf bytes.Buffer
if err := noHooks.template(navFragPage).ExecuteTemplate(&buf, "repo-tabs", d); err != nil {
t.Fatal(err)
}
return strings.Contains(buf.String(), `/ci"`)
}
for _, tc := range []struct {
name string
data pageData
want bool
}{
{"runs: shown to anyone who may see CI",
pageData{Repo: "quarry", Counts: navCounts{HasCI: true}}, true},
{"runs: shown even after someone declined — the runs are real",
pageData{Repo: "quarry", Counts: navCounts{HasCI: true, CIDeclinedBy: "dan"}}, true},
{"no CI: shown to someone who could set it up",
pageData{Repo: "orchard", MaySetUpCI: true}, true},
{"no CI: hidden from someone who could not",
pageData{Repo: "orchard"}, false},
{"no CI, declined: hidden even from someone who could set it up",
pageData{Repo: "orchard", MaySetUpCI: true, Counts: navCounts{CIDeclinedBy: "dan"}}, false},
{"without the CI letter: never shown, runs or not",
pageData{Repo: "quarry", Counts: navCounts{HasCI: true}, HideCI: true}, false},
} {
if got := has(tc.data); got != tc.want {
t.Errorf("FAIL %s: tab present = %v, want %v", tc.name, got, tc.want)
} else {
t.Logf("ok %s", tc.name)
}
}
}
// The empty CI page teaches whoever could set CI up, offers the decline
// only to someone who may give that answer, and tells everyone else the
// plain fact.
func TestEmptyCIPageTeachesWhoeverCouldSetItUp(t *testing.T) {
body := func(d pageData) string {
t.Helper()
var buf bytes.Buffer
if err := noHooks.template(ciPage).ExecuteTemplate(&buf, "ci-body", d); err != nil {
t.Fatal(err)
}
return buf.String()
}
check := func(name, got, hook string, want bool) {
t.Helper()
if strings.Contains(got, `data-hook="`+hook+`"`) != want {
t.Errorf("FAIL %s: %s present = %v, want %v", name, hook, !want, want)
} else {
t.Logf("ok %s: %s present = %v", name, hook, want)
}
}
admin := body(pageData{Repo: "orchard", MaySetUpCI: true, CanAckGate: true, WorkerGroup: "orchard"})
check("admin", admin, "ci-setup", true)
check("admin", admin, "ci-decline", true)
if !strings.Contains(admin, "<code>orchard</code>") {
t.Error("FAIL admin: the repository's worker group is not named")
}
dev := body(pageData{Repo: "orchard", MaySetUpCI: true, WorkerGroup: "orchard"})
check("developer", dev, "ci-setup", true)
check("developer", dev, "ci-decline", false)
if !strings.Contains(dev, "needs an admin") {
t.Error("FAIL developer: the admin-only step is not marked as such")
}
reader := body(pageData{Repo: "orchard"})
check("reader", reader, "ci-setup", false)
check("reader", reader, "ci-decline", false)
}
// On a tracking mirror the pipeline editor's commit would be refused, so
// the first step says where a pipeline goes instead: upstream, picked
// up on the next pull.
func TestEmptyCIPageOnAMirrorPointsUpstream(t *testing.T) {
render := func(d pageData) string {
t.Helper()
var buf bytes.Buffer
if err := noHooks.template(ciPage).ExecuteTemplate(&buf, "ci-body", d); err != nil {
t.Fatal(err)
}
return buf.String()
}
mirror := pageData{Repo: "zit", MaySetUpCI: true, CanAckGate: true, WorkerGroup: "zit"}
mirror.Posture.Mirror.Tracking, mirror.Posture.Mirror.URL = true, "https://fossil.example.org/zit"
got := render(mirror)
if !strings.Contains(got, `data-hook="ci-setup-upstream"`) || !strings.Contains(got, `href="https://fossil.example.org/zit"`) {
t.Error("a mirror's setup does not point at its upstream")
}
if strings.Contains(got, `/ci/pipeline"`) {
t.Error("a mirror's setup offers the pipeline editor, whose commit it would refuse")
}
if !strings.Contains(got, `data-hook="ci-decline"`) {
t.Error("a mirror's admin cannot decline CI")
}
native := render(pageData{Repo: "orchard", MaySetUpCI: true, WorkerGroup: "orchard"})
if strings.Contains(native, `data-hook="ci-setup-upstream"`) || !strings.Contains(native, `/ci/pipeline"`) {
t.Error("a native repository lost the pipeline editor step")
}
}
// recDecline is a caps plane that records the CI-decline answer it is
// given, so the route's effect can be asserted rather than assumed.
type recDecline struct {
stubCaps
who *string
}
func (r recDecline) CIDeclined(string) (string, string) { return *r.who, "" }
func (r recDecline) SetCIDeclined(_, who string) error { *r.who = who; return nil }
// Through the handler, not the template: the answer is recorded as the
// admin who gave it, withdrawn by the same route, and refused to anyone
// who may not give it. The refusal case wires a verifier, because an
// ungated forge waves everyone through by design and would pass it
// vacuously.
func TestCIDeclineRouteRecordsAndRefuses(t *testing.T) {
ix := landingIndex(t, false)
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
CI: fakeCI{}}); err != nil {
t.Fatal(err)
}
post := func(srv *httptest.Server, form url.Values) *http.Response {
t.Helper()
req, _ := http.NewRequest("POST", srv.URL+"/r/alpha/ci/decline", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
res, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
return res
}
var who string
admin := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(recDecline{stubCaps{letters: "a8"}, &who})}))
t.Cleanup(admin.Close)
post(admin, url.Values{})
if who != "dan" {
t.Fatalf("FAIL an admin's decline recorded %q, want dan", who)
}
t.Log("ok an admin's answer is recorded as theirs")
post(admin, url.Values{"undo": {"1"}})
if who != "" {
t.Fatalf("FAIL withdrawing left %q recorded", who)
}
t.Log("ok the same route withdraws it")
who = "unchanged"
reader := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(recDecline{stubCaps{letters: "o8"}, &who})},
Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied })))
t.Cleanup(reader.Close)
post(reader, url.Values{})
if who != "unchanged" {
t.Fatalf("FAIL someone without `a` changed the answer to %q", who)
}
t.Log("ok someone without `a` cannot give the answer")
}