package web
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"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)
}
}