Skip to content
quarry Artifact [2b4ae9f4a4]
Login

Artifact [2b4ae9f4a4]

Artifact 2b4ae9f4a482bd7ffa944981f9fd9a25c7045c4852f1249acfcbfafc59a0ed58:


package web

import (
	"net/http"
	"net/http/httptest"
	"net/url"
	"regexp"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// The rail is built from the same list the sections are, so each rail
// entry is a section on the page and they come in the same order.
func TestSettingsRailNamesEverySectionInOrder(t *testing.T) {
	body := renderSettings(t, pageData{Repo: "alpha", HasHooks: true, Caps: &capsPanel{}})
	rail := body[strings.Index(body, `class="set-rail"`):strings.Index(body, `class="set-body"`)]
	var hrefs []string
	for _, m := range regexp.MustCompile(`href="#([a-z-]+)"`).FindAllStringSubmatch(rail, -1) {
		hrefs = append(hrefs, m[1])
	}
	var ids []string
	for _, m := range regexp.MustCompile(`<(?:section|h3) id="([a-z-]+)"`).FindAllStringSubmatch(body, -1) {
		ids = append(ids, m[1])
	}
	if len(hrefs) < 5 || strings.Join(hrefs, " ") != strings.Join(ids, " ") {
		t.Fatalf("rail %v\nsections %v", hrefs, ids)
	}
}

// Every destructive button asks through the one dialog; the dialog is
// an enhancement, so each such button is a real submit inside a real
// post form and works with no script at all.
func TestSettingsConfirmButtonsAreRealSubmits(t *testing.T) {
	body := renderSettings(t, pageData{Repo: "alpha", HasHooks: true, Caps: &capsPanel{},
		Hooks: []HookSub{{ID: 1, URL: "https://x/y"}}, Protect: &protectPanel{Rules: []protectRule{{Branch: "trunk"}}},
		Roster: &rosterPanel{Rows: []rosterRow{{Name: "bob", Caps: "v"}}}})
	if n := strings.Count(body, `<dialog id="confirm"`); n != 1 {
		t.Fatalf("%d confirm dialogs, want one", n)
	}
	confirms := regexp.MustCompile(`<button[^>]*data-confirm=`).FindAllString(body, -1)
	if len(confirms) < 3 {
		t.Fatalf("only %d data-confirm buttons in %q", len(confirms), confirms)
	}
	for _, b := range confirms {
		if !strings.Contains(b, `type="submit"`) {
			t.Errorf("data-confirm on a non-submit: %s", b)
		}
	}
	for _, m := range regexp.MustCompile(`(?s)<form[^>]*>.*?</form>`).FindAllString(body, -1) {
		if strings.Contains(m, "data-confirm") && !strings.Contains(m, `method="post"`) {
			t.Errorf("confirm button outside a post form: %.80s", m)
		}
	}
}

// The footer flows on Settings — the last Save used to sit under the
// fixed one — and nowhere else.
func TestSettingsFooterFlows(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a"}}
	srv := peopleServer(t, plane)
	if body := asDan(t, srv, "/r/alpha/settings"); !strings.Contains(body, `<body class="flow-footer"`) {
		t.Fatal("settings body does not carry flow-footer")
	}
	if body := asDan(t, srv, "/"); strings.Contains(body, "flow-footer") {
		t.Fatal("home page carries flow-footer")
	}
	for _, want := range []string{"body.flow-footer footer { position: static;", ".set-body .caps-table tr { display: grid;"} {
		if !strings.Contains(sheetLiteral, want) {
			t.Errorf("sheet lacks %q", want)
		}
	}
}

// A row's Remove posts the person page's own revoke with back=settings:
// the role goes, the page comes back to People. The last admin's row
// has no working Remove, and posting it anyway is refused by the guard
// and reported on the settings page, the role untouched.
func TestSettingsRowRemoveRevokesAndReturns(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "bob": "v"}}
	srv := peopleServer(t, plane)
	body := asDan(t, srv, "/r/alpha/settings")
	if !strings.Contains(body, `<button type="submit" disabled>Remove</button>`) {
		t.Fatal("the only admin's Remove is not disabled")
	}
	resp := postAsDan(t, srv, "/r/alpha/people/bob", url.Values{"revoke": {"1"}, "back": {"settings"}})
	if resp.StatusCode != http.StatusSeeOther || !strings.HasSuffix(resp.Header.Get("Location"), "/r/alpha/settings#people") {
		t.Fatalf("%d -> %s", resp.StatusCode, resp.Header.Get("Location"))
	}
	if plane.stored["bob"] != "" {
		t.Fatalf("bob still holds %q", plane.stored["bob"])
	}
	resp = postAsDan(t, srv, "/r/alpha/people/dan", url.Values{"revoke": {"1"}, "back": {"settings"}})
	loc, _ := url.Parse(resp.Header.Get("Location"))
	if resp.StatusCode != http.StatusSeeOther || loc.Path != "/r/alpha/settings" || loc.Query().Get("e") == "" || loc.Fragment != "people" {
		t.Fatalf("refusal went %d -> %s", resp.StatusCode, resp.Header.Get("Location"))
	}
	if plane.stored["dan"] != "a" {
		t.Fatalf("dan's admin was removed: %q", plane.stored["dan"])
	}
}

type recProtect struct {
	rules map[string][]oltp.Protection
	set   map[string][]string
}

func (p *recProtect) MayJoin(string, string, string) bool                { return true }
func (p *recProtect) Protections(repo string) ([]oltp.Protection, error) { return p.rules[repo], nil }
func (p *recProtect) SetProtection(_, branch string, owners []string) error {
	p.set[branch] = owners
	return nil
}

// A rule's Remove names its row with drop=N; that row is cleared and the
// others are written as the form holds them.
func TestProtectDropRemovesOneRule(t *testing.T) {
	plane := &recProtect{set: map[string][]string{}, rules: map[string][]oltp.Protection{
		"alpha": {{Branch: "trunk", Owners: []string{"alice"}}, {Branch: "release-*", Owners: []string{"bob"}}}}}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}}), Protect: plane}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
		WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
		WithCaps(&memCaps{stored: map[string]string{"dan": "a"}}), WithProtect(plane)}))
	t.Cleanup(srv.Close)
	resp := postAsDan(t, srv, "/r/alpha/settings/protect", url.Values{
		"branch-0": {"trunk"}, "owners-0": {"alice"},
		"branch-1": {"release-*"}, "owners-1": {"bob"},
		"branch-2": {""}, "owners-2": {""}, "drop": {"0"}})
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("status %d", resp.StatusCode)
	}
	if got := plane.set["trunk"]; len(got) != 0 {
		t.Fatalf("trunk kept owners %v", got)
	}
	if got := strings.Join(plane.set["release-*"], " "); got != "bob" {
		t.Fatalf("release-* owners %q", got)
	}
}

// Every picker names a datalist, and every named datalist is on the
// page when there is anything to list — an id that dangles is a picker
// that silently offers nothing.
func TestSettingsPickersNameDatalistsOnThePage(t *testing.T) {
	body := renderSettings(t, pageData{Repo: "alpha", HasHooks: true, Caps: &capsPanel{},
		Protect: &protectPanel{Rules: []protectRule{{Branch: "trunk"}}, Blank: 1},
		Roster:  &rosterPanel{}, Logins: []string{"dan"}, CheckNames: []string{"go"}, TopicNames: []string{"ops"},
		EventNouns: []string{"push"}})
	ids := map[string]bool{}
	for _, m := range regexp.MustCompile(`<datalist id="([a-z-]+)"`).FindAllStringSubmatch(body, -1) {
		ids[m[1]] = true
	}
	refs := regexp.MustCompile(`<input[^>]* list="([a-z-]+)"`).FindAllStringSubmatch(body, -1)
	if len(refs) < 5 {
		t.Fatalf("only %d inputs name a list", len(refs))
	}
	for _, m := range refs {
		if !ids[m[1]] {
			t.Errorf("input names list %q, no datalist has that id", m[1])
		}
	}
}

// A chip picker is script over a plain named text field: the field
// keeps its name and its space-separated value, so the post the handler
// reads is the same one it read before the chips.
func TestSettingsChipInputsArePlainTextFields(t *testing.T) {
	body := renderSettings(t, pageData{Repo: "alpha", HasHooks: true, Caps: &capsPanel{},
		Protect: &protectPanel{Rules: []protectRule{{Branch: "trunk", Owners: "dan bob", Checks: "go vet"}}, Blank: 1},
		Hooks:   []HookSub{{ID: 1, URL: "https://x/y", Events: "push"}}, EventNouns: []string{"push", "ticket"}})
	chips := regexp.MustCompile(`<input[^>]*data-chips[^>]*>`).FindAllString(body, -1)
	if len(chips) < 5 {
		t.Fatalf("only %d chip inputs: %q", len(chips), chips)
	}
	for _, in := range chips {
		if !strings.Contains(in, ` name="`) || strings.Contains(in, `type="hidden"`) {
			t.Errorf("chip input is not a named text field: %s", in)
		}
		if !strings.Contains(in, ` list="`) {
			t.Errorf("chip input without a list: %s", in)
		}
	}
	if !strings.Contains(body, `value="dan bob"`) || !strings.Contains(body, `value="go vet"`) {
		t.Error("owners or checks lost their space-separated value")
	}
}

type seenChecks struct {
	recProtect
	seen  []string
	rules []oltp.CheckRule
}

func (s *seenChecks) CheckNamesSeen(string, time.Time) ([]string, error) { return s.seen, nil }
func (s *seenChecks) CheckRules(string) ([]oltp.CheckRule, error)        { return s.rules, nil }

// The check picker offers what CI has reported and what a rule already
// requires, once each, sorted — a rule can name a check that has not
// run yet, and a check that has run may be required by no rule.
func TestCheckNamesUnionSeenAndRequired(t *testing.T) {
	p := &seenChecks{seen: []string{"vet", "go"}, rules: []oltp.CheckRule{{Branch: "trunk", Checks: []string{"go", "lint"}}}}
	got := checkNamesFor(p, "alpha")
	if strings.Join(got, " ") != "go lint vet" {
		t.Fatalf("got %v", got)
	}
	if got := checkNamesFor(&recProtect{}, "alpha"); len(got) != 0 {
		t.Fatalf("plane with no checks gave %v", got)
	}
}

// The fossil filter earns its place only on a long list.
func TestFossilFilterOnlyOnLongLists(t *testing.T) {
	rows := func(n int) []fossilRow {
		out := make([]fossilRow, n)
		for i := range out {
			out[i].Name = "s" + strings.Repeat("x", i)
		}
		return out
	}
	short := renderSettings(t, pageData{Repo: "alpha", Caps: &capsPanel{}, Fossil: &fossilPanel{Supported: true, Rows: rows(5)}})
	long := renderSettings(t, pageData{Repo: "alpha", Caps: &capsPanel{}, Fossil: &fossilPanel{Supported: true, Rows: rows(20)}})
	if strings.Contains(short, `data-filter="fossil-rows"`) {
		t.Error("filter on a short list")
	}
	if !strings.Contains(long, `data-filter="fossil-rows"`) || !strings.Contains(long, `id="fossil-rows"`) {
		t.Error("long list has no filter, or the filter names a body that is not there")
	}
}

// A rule is read as a sentence and edited under a disclosure, but the
// fields the handler indexes are unchanged: branch/owners/checks/premerge
// at the row's own number, and the blank row at Blank.
func TestRulesKeepTheirIndexedFields(t *testing.T) {
	body := renderSettings(t, pageData{Repo: "alpha", Caps: &capsPanel{},
		Protect: &protectPanel{Blank: 2, Rules: []protectRule{
			{Branch: "trunk", Owners: "dan", OwnerList: []string{"dan"}},
			{Branch: "release-*", Checks: "go", CheckList: []string{"go"}, Premerge: true},
		}}})
	for _, name := range []string{"branch-0", "owners-0", "checks-0", "premerge-0",
		"branch-1", "owners-1", "checks-1", "premerge-1",
		"branch-2", "owners-2", "checks-2", "premerge-2"} {
		if !strings.Contains(body, `name="`+name+`"`) {
			t.Errorf("no field named %q", name)
		}
	}
	if n := strings.Count(body, `class="rule-said"`); n != 2 {
		t.Errorf("%d rules read as sentences, want 2", n)
	}
	// Each sentence names its own branch and the people it lets in.
	said := regexp.MustCompile(`(?s)<p class="rule-said">(.*?)</p>`).FindAllStringSubmatch(body, -1)
	if len(said) != 2 || !strings.Contains(said[0][1], "trunk") || !strings.Contains(said[0][1], "dan") {
		t.Errorf("sentence does not name its branch and owner: %v", said)
	}
	if !strings.Contains(said[1][1], "go") {
		t.Error("a required check is not in the rule's sentence")
	}
}

// The two ways a rule is quietly dead are said on the rule. With no
// roster to compare against, no owner is accused.
func TestRuleWarnsOnOwnersAndChecksThatCannotWork(t *testing.T) {
	p := &protectPanel{Blank: 1, Rules: []protectRule{
		{Branch: "trunk", Owners: "dan ghost", Checks: "go stale"}}}
	roster := &rosterPanel{Rows: []rosterRow{{Name: "dan"}}}
	annotateRules(p, roster, []string{"go"})
	r := p.Rules[0]
	if strings.Join(r.Unknown, " ") != "ghost" {
		t.Errorf("unknown owners %v, want just the one with no role", r.Unknown)
	}
	if strings.Join(r.Stale, " ") != "stale" {
		t.Errorf("stale checks %v, want the one nothing reported", r.Stale)
	}
	body := renderSettings(t, pageData{Repo: "alpha", Caps: &capsPanel{}, Protect: p, Roster: roster})
	if n := strings.Count(body, `class="rule-warn"`); n != 2 {
		t.Errorf("%d warnings drawn, want one per kind", n)
	}

	// No roster is not the same fact as "this owner holds no role".
	blind := &protectPanel{Rules: []protectRule{{Branch: "trunk", Owners: "dan"}}}
	annotateRules(blind, nil, nil)
	if len(blind.Rules[0].Unknown) != 0 {
		t.Errorf("owners accused with no roster to check against: %v", blind.Rules[0].Unknown)
	}
}

// Every matching rule applies, so the probe answers with the union and
// matches globs the way the gate does.
func TestProbeUnionsEveryMatchingRule(t *testing.T) {
	p := &protectPanel{Rules: []protectRule{
		{Branch: "release-*", Owners: "dan", Checks: "go"},
		{Branch: "release-1.2", Owners: "bob", Checks: "vet"},
		{Branch: "trunk", Owners: "eve"},
	}}
	annotateRules(p, nil, nil)
	got := probeFor(p, "release-1.2")
	if !got.Matched || len(got.Rules) != 2 {
		t.Fatalf("matched %v", got.Rules)
	}
	if strings.Join(got.Owners, " ") != "bob dan" || strings.Join(got.Checks, " ") != "go vet" {
		t.Errorf("owners %v checks %v: not the union of both rules", got.Owners, got.Checks)
	}
	if miss := probeFor(p, "topic"); miss == nil || miss.Matched {
		t.Errorf("a branch no rule names came back governed: %v", miss)
	}
	if probeFor(p, "") != nil || probeFor(nil, "trunk") != nil {
		t.Error("probe answered a question nobody asked")
	}
	// The probe is a GET form, so the answer survives with no script.
	body := renderSettings(t, pageData{Repo: "alpha", Caps: &capsPanel{}, Protect: p, Probe: got})
	if !strings.Contains(body, `<form method="get"`) || !strings.Contains(body, `class="probe-said"`) {
		t.Error("the probe is not a plain GET form with its answer on the page")
	}
}