Skip to content
quarry Artifact [dbd31f467d]
Login

Artifact [dbd31f467d]

Artifact dbd31f467d1835c3c179be16887678e90a0af0e4cb9717a686debc213e8cf8ca:


// 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, `data-hook="ci-setup-starters"`) ||
		!strings.Contains(native, `/ci/pipeline?new=1&amp;template=test"`) || !strings.Contains(native, `/ci/pipeline?new=1&amp;template=deploy"`) {
		t.Error("a native repository lost the pipeline editor's starters")
	}
}

// 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")
}