package web
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// memCaps is a caps plane in a map: stored strings are the effective
// set (no categories), and the guard refuses self-demotion from a.
type memCaps struct {
stored map[string]string
cats map[string]string // "repo/category" → string; fleet under ""
}
func (m *memCaps) cat(repo, c string) string {
if v, ok := m.cats[repo+"/"+c]; ok {
return v
}
if v, ok := m.cats["/"+c]; ok {
return v
}
return map[string]string{"nobody": "gjorzh2", "anonymous": "mnc3C"}[c]
}
func (m *memCaps) CapsOf(acct, _ string) CapSet { return oltp.CapSet(m.stored[acct]) }
func (m *memCaps) CapHolders(string) ([]CapHolder, error) {
names := make([]string, 0, len(m.stored))
for n := range m.stored {
names = append(names, n)
}
sort.Strings(names)
var out []CapHolder
for _, n := range names {
out = append(out, CapHolder{Account: n, Caps: m.stored[n]})
}
return out, nil
}
func (m *memCaps) CategoryCaps(repo, cat string) string { return m.cat(repo, cat) }
func (m *memCaps) SetCaps(acct, _, caps string) error { m.stored[acct] = caps; return nil }
func (m *memCaps) SetCategoryCaps(repo, cat, caps string) error {
if m.cats == nil {
m.cats = map[string]string{}
}
m.cats[repo+"/"+cat] = caps
return nil
}
func (m *memCaps) GuardCaps(actor, acct, repo, caps string) (string, error) {
if actor == acct && oltp.CapSet(m.stored[acct]).HasAny("a") && !oltp.CapSet(caps).HasAny("a") {
return "", oltp.Refusal{Reason: "that would remove your own admin on " + repo}
}
return "", nil
}
func peopleServer(t *testing.T, plane *memCaps) *httptest.Server {
t.Helper()
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}})}); 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(plane)}))
t.Cleanup(srv.Close)
return srv
}
func postAsDan(t *testing.T, srv *httptest.Server, path string, form url.Values) *http.Response {
t.Helper()
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
// RFC 0008 §IA: the People section reads each string as a role and a
// sentence; the raw per-account inputs are gone from Settings.
func TestSettingsReadsPeopleAsRolesAndSentences(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Maintain.Caps() + "e", "eve": ""}}
srv := peopleServer(t, plane)
body := asDan(t, srv, "/r/alpha/settings")
for _, want := range []string{`href="/r/alpha/people/bob"`, `<span class="chip role">Maintainer</span>`, `chip plus">+ `, "bob can "} {
if !strings.Contains(body, want) {
t.Fatalf("settings lacks %q", want)
}
}
if strings.Contains(body, `name="acct-bob"`) {
t.Fatal("raw per-account inputs are still on Settings")
}
if strings.Contains(body, `href="/r/alpha/people/eve"`) {
t.Fatal("eve holds nothing here and should not be listed")
}
}
// The editor: a role radio, the abilities as boxes, the string under a
// disclosure. Saving a role change writes the preset and says so.
func TestPersonEditorSavesARoleChange(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Write.Caps()}}
srv := peopleServer(t, plane)
body := asDan(t, srv, "/r/alpha/people/bob")
for _, want := range []string{`value="Developer" checked`, `name="ab"`, `name="raw"`, `name="was-role" value="Developer"`} {
if !strings.Contains(body, want) {
t.Fatalf("editor lacks %q", want)
}
}
resp := postAsDan(t, srv, "/r/alpha/people/bob", url.Values{
"role": {"Maintainer"}, "was-role": {"Developer"},
"raw": {oltp.Write.Caps()}, "was-raw": {oltp.Write.Caps()},
"ab": {"u"}, "was-ab": {"u"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status %d", resp.StatusCode)
}
if plane.stored["bob"] != oltp.Maintain.Caps() {
t.Fatalf("stored %q", plane.stored["bob"])
}
loc := resp.Header.Get("Location")
if !strings.Contains(loc, "Developer+%E2%86%92+Maintainer") {
t.Fatalf("redirect does not say what changed: %s", loc)
}
if got := asDan(t, srv, loc); !strings.Contains(got, "Saved. Developer → Maintainer") {
t.Fatal("the change line is not shown after save")
}
}
// Bulletproofing: the guard's refusal is shown where the save was, and
// nothing is written.
func TestPersonEditorShowsARefusalInsteadOfSaving(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a"}}
srv := peopleServer(t, plane)
resp := postAsDan(t, srv, "/r/alpha/people/dan", url.Values{
"role": {"Developer"}, "was-role": {"Admin"}, "raw": {"a"}, "was-raw": {"a"}})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d", resp.StatusCode)
}
b, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(b), "Not saved: that would remove your own admin") {
t.Fatal("refusal not shown")
}
if plane.stored["dan"] != "a" {
t.Fatalf("saved anyway: %q", plane.stored["dan"])
}
}
// Adding from the roster form writes the role's string and lands on
// the person's page; an unknown login is refused in words.
func TestRosterAddsAPersonByRole(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "eve": ""}}
srv := peopleServer(t, plane)
resp := postAsDan(t, srv, "/r/alpha/people", url.Values{"name": {"eve"}, "role": {"Reader"}})
if resp.StatusCode != http.StatusSeeOther || !strings.HasPrefix(resp.Header.Get("Location"), "/r/alpha/people/eve?changed=added+as+Reader") {
t.Fatalf("%d %s", resp.StatusCode, resp.Header.Get("Location"))
}
if plane.stored["eve"] != oltp.Read.Caps() {
t.Fatalf("stored %q", plane.stored["eve"])
}
resp = postAsDan(t, srv, "/r/alpha/people", url.Values{"name": {"ghost"}, "role": {"Reader"}})
if loc := resp.Header.Get("Location"); !strings.Contains(loc, "no+account+named+ghost") {
t.Fatalf("unknown login: %s", loc)
}
}
// nextCaps: the string wins when edited; a new role replaces; boxes
// are the string; untouched boxes keep the stored bytes.
func TestNextCapsReadsTheFormInOrder(t *testing.T) {
dev := oltp.Write.Caps()
base := url.Values{"role": {"Developer"}, "was-role": {"Developer"}, "raw": {dev}, "was-raw": {dev}, "was-ab": {dev}, "ab": strings.Split(dev, "")}
if got := nextCaps(base, dev); got != dev {
t.Fatalf("untouched form wrote %q", got)
}
f := url.Values{}
for k, v := range base {
f[k] = v
}
f.Set("raw", "iy")
if got := nextCaps(f, dev); got != "iy" {
t.Fatalf("edited string lost: %q", got)
}
f = url.Values{}
for k, v := range base {
f[k] = v
}
f["ab"] = append(f["ab"], "5") // moderate the forum
if got := nextCaps(f, dev); oltp.Closure(got) != oltp.Closure(dev+"5") || strings.Contains(got, "3") {
t.Fatalf("ticked box: %q", got)
}
f.Set("revoke", "1")
if got := nextCaps(f, dev); got != "" {
t.Fatalf("revoke: %q", got)
}
}
// RFC 0008 §Visibility: Settings asks who can see this first, as
// presets; a preset writes the two category strings.
func TestVisibilityPresetsWriteTheCategoryStrings(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a"}}
srv := peopleServer(t, plane)
body := asDan(t, srv, "/r/alpha/settings")
if !strings.Contains(body, `value="Public project" checked`) || strings.Contains(body, `name="private"`) {
t.Fatal("settings should open on the visibility presets, without the old checkbox")
}
resp := postAsDan(t, srv, "/r/alpha/settings/visibility", url.Values{"visibility": {"Members only"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status %d", resp.StatusCode)
}
if plane.cat("alpha", "nobody") != "" || plane.cat("alpha", "anonymous") != "gjorzh2mnc3C" {
t.Fatalf("strings after Members only: %q / %q", plane.cat("alpha", "nobody"), plane.cat("alpha", "anonymous"))
}
if body := asDan(t, srv, "/r/alpha/settings"); !strings.Contains(body, `value="Members only" checked`) {
t.Fatal("the preset does not read back")
}
// Hand-typed strings that match no preset read as Custom, decoded.
plane.cats["alpha/nobody"], plane.cats["alpha/anonymous"] = "o", "o"
if body := asDan(t, srv, "/r/alpha/settings"); !strings.Contains(body, `value="Custom" checked`) || !strings.Contains(body, "visitors can ") {
t.Fatal("Custom is not shown decoded")
}
}
// Private is a wizard: whoever reaches the repository only through
// the categories is named and offered a role before the strings blank.
func TestTakingPrivateOffersRolesToWhoWouldLoseAccess(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "eve": "", "bob": oltp.Write.Caps()}}
srv := peopleServer(t, plane)
resp := postAsDan(t, srv, "/r/alpha/settings/visibility", url.Values{"visibility": {"Private"}})
if loc := resp.Header.Get("Location"); loc != "/r/alpha/settings/private" {
t.Fatalf("went to %s, not the wizard", loc)
}
if plane.cat("alpha", "nobody") != "gjorzh2" {
t.Fatal("the strings blanked before anyone was asked")
}
body := asDan(t, srv, "/r/alpha/settings/private")
if !strings.Contains(body, `name="role-eve"`) || strings.Contains(body, `name="role-bob"`) {
t.Fatal("the wizard should name eve and not bob")
}
resp = postAsDan(t, srv, "/r/alpha/settings/private", url.Values{"confirm": {"1"}, "role-eve": {"Reader"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status %d", resp.StatusCode)
}
if plane.stored["eve"] != oltp.Read.Caps() || plane.cat("alpha", "nobody") != "" || plane.cat("alpha", "anonymous") != "" {
t.Fatalf("after confirm: eve=%q nobody=%q anon=%q", plane.stored["eve"], plane.cat("alpha", "nobody"), plane.cat("alpha", "anonymous"))
}
// With nobody to lose access, Private is one step.
plane.stored["eve"] = oltp.Read.Caps()
plane.cats = map[string]string{}
resp = postAsDan(t, srv, "/r/alpha/settings/visibility", url.Values{"visibility": {"Private"}})
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/r/alpha/settings?saved=1") {
t.Fatalf("went to %s", loc)
}
}