Skip to content
quarry Artifact [659c4dfe9b]
Login

Artifact [659c4dfe9b]

Artifact 659c4dfe9b46167bab36703aefb52ce8c3671771a14665a70cee1dbf6561c67b:


package oltp

import (
	"strings"
	"testing"
)

// The presets are the RFC's most consequential decision, so each letter
// set is asserted, not described.
func TestPresetsAreExactlyWhatTheRFCSays(t *testing.T) {
	want := map[string]string{
		"Reader":     "bcghjoprz2378BC",
		"Developer":  "uv3bBC78",
		"Maintainer": "uv45bBlqCA78",
		"Admin":      "a",
	}
	if len(Presets) != len(want) {
		t.Fatalf("%d presets, want %d", len(Presets), len(want))
	}
	for _, p := range Presets {
		if p.Caps != want[p.Name] {
			t.Errorf("%s = %q, want %q", p.Name, p.Caps, want[p.Name])
		}
		if PresetOf(p.Caps) != p.Name {
			t.Errorf("%s does not read back as itself", p.Name)
		}
	}
	// The write role's gap, closed: a Developer can post and chat.
	dev := CapSet(Closure("uv3bBC7"))
	if !dev.HasAny("3") || !dev.HasAny("C") || !dev.HasAny("b") {
		t.Error("a Developer cannot post, chat, or attach")
	}
}

// Fossil's implications: two strings it treats alike compare alike.
func TestClosureFollowsFossilsImplications(t *testing.T) {
	for in, want := range map[string]string{
		"w":   "cnrw",
		"5":   "2345",
		"k":   "jkm",
		"i":   "io",
		"6":   "23456",
		"uv":  "uv",
		"a b": "ab",
	} {
		if got := Closure(in); got != want {
			t.Errorf("Closure(%q) = %q, want %q", in, got, want)
		}
	}
}

// A string that is a role plus a little reads as that role plus the
// difference, in sentences — the chip the People table draws.
func TestInferReadsARoleAndItsDifference(t *testing.T) {
	rd := Infer("uv3bBC75")
	if rd.Role != "Developer" || rd.Exact || len(rd.Plus) == 0 {
		t.Fatalf("Developer + moderate read as %+v", rd)
	}
	if rd.Plus[0].Sentence != "post without moderation" && rd.Plus[len(rd.Plus)-1].Sentence != "moderate the forum" {
		t.Errorf("plus = %v", sentences(rd.Plus))
	}
	rd = Infer("uv3bB78") // Developer minus chat
	if rd.Role != "Developer" || len(rd.Minus) != 1 || rd.Minus[0].Sentence != "chat" {
		t.Errorf("Developer minus chat read as %+v", rd)
	}
	if rd := Infer("a"); rd.Role != "Admin" || !rd.Exact {
		t.Errorf("a read as %+v", rd)
	}
	// Far from everything: Custom, not a misleading nearest.
	if rd := Infer("iy"); rd.Role != "Custom" {
		t.Errorf("a bot's iy read as %q", rd.Role)
	}
}

// Compile is the editor: a radio and checkboxes become the string, and
// the string reads back as the same radio and checkboxes.
func TestCompileRoundTripsThroughInfer(t *testing.T) {
	s := Compile("Developer", []string{"moderate the forum"}, []string{"chat"})
	rd := Infer(s)
	if rd.Role != "Developer" || len(rd.Plus) < 1 || len(rd.Minus) != 1 {
		t.Fatalf("compiled %q reads as %+v", s, rd)
	}
	if Compile("Nope", nil, nil) != "" {
		t.Error("an unknown role compiled to something")
	}
	if Compile("Reader", nil, nil) != Closure("bcghjoprz2378BC") {
		t.Error("Reader alone does not compile to its preset")
	}
}

// Every letter in a string is accounted for in its decoding: nothing a
// person typed is silently dropped from the explanation.
func TestDecodeAccountsForEveryLetter(t *testing.T) {
	got := strings.Join(Decode("uv3bBC7xD"), " | ")
	for _, want := range []string{"post in the forum", "chat", "see private branches", "reader category", "developer category"} {
		if !strings.Contains(got, want) {
			t.Errorf("decode lacks %q:\n%s", want, got)
		}
	}
	if got := Decode("Z"); len(got) != 1 || got[0] != "letter Z" {
		t.Errorf("an unknown letter vanished: %v", got)
	}
}

// The sentence the People table shows, computed from the EFFECTIVE set.
func TestExplainSaysWhatAPersonCanAndCannotDo(t *testing.T) {
	db := testDB(t)
	db.SetCaps("bob", "alpha", "uv")
	got := Explain("bob", db.EffectiveCaps("bob", "alpha"))
	if !strings.Contains(got, "check in") || !strings.Contains(got, "but not") || !strings.Contains(got, "post in the forum") {
		t.Errorf("the old write role's gap is not stated: %q", got)
	}
	db.SetCaps("bob", "alpha", Compile("Developer", nil, nil))
	got = Explain("bob", db.EffectiveCaps("bob", "alpha"))
	if strings.Contains(got, "not post in the forum") || !strings.Contains(got, "chat") {
		t.Errorf("a Developer is still described as unable to talk: %q", got)
	}
	if got := Explain("dan", CapSet("s")); !strings.Contains(got, "site administrator") {
		t.Errorf("%q", got)
	}
}

// Visibility is a preset over the two category strings, and reads back.
func TestVisibilityPresetsRoundTrip(t *testing.T) {
	for _, v := range Visibilities {
		if VisibilityOf(v.Nobody, v.Anonymous) != v.Name {
			t.Errorf("%s does not read back", v.Name)
		}
	}
	if VisibilityOf(StockNobody, StockAnonymous) != "Custom" {
		t.Error("fossil's stock defaults read as a preset; they differ from Public by design")
	}
	if VisibilityOf("", "") != "Private" {
		t.Error("blank categories are not Private")
	}
}

func sentences(as []Ability) []string {
	out := make([]string, len(as))
	for i, a := range as {
		out[i] = a.Sentence
	}
	return out
}

// presetCaps is the preset's string, looked up rather than repeated: a
// test that hard-codes the letters it is checking the meaning of proves
// only that two copies of one string agree.
func presetCaps(t *testing.T, name string) string {
	t.Helper()
	for _, p := range Presets {
		if p.Name == name {
			return p.Caps
		}
	}
	t.Fatalf("no preset %q", name)
	return ""
}

// Reader must mean what it says ON A REAL REPOSITORY, not just as a
// string. The bug this pins was invisible to every letter-set test
// above, because the letters were right and the EXPANSION was the
// problem: "u" is fossil's reader category, effective() expands it, and
// a repository that has not overridden its categories resolves it
// through fossil's shipped kptw — wiki editing and ticket editing. So
// granting "reads, asks, discusses" handed over two edits, and nothing
// here noticed because nothing here resolved a repository.
func TestReaderCannotEditOnAStockRepository(t *testing.T) {
	db := teamDB(t)
	if err := db.AddRepo("quarry", "/srv/quarry", "", ""); err != nil {
		t.Fatal(err)
	}
	// The stock categories, exactly as an import or a settings save
	// writes them — this is the ordinary state, not a contrived one.
	for cat, caps := range map[string]string{
		"nobody": StockNobody, "anonymous": StockAnonymous,
		"reader": StockReader, "developer": StockDeveloper,
	} {
		if err := db.SetCategoryCaps("quarry", cat, caps); err != nil {
			t.Fatal(err)
		}
	}
	if err := db.SetCaps("ann", "quarry", presetCaps(t, "Reader")); err != nil {
		t.Fatal(err)
	}
	eff := db.EffectiveCaps("ann", "quarry")

	// The whole point of the role.
	for letter, what := range map[string]string{
		"k": "edit wiki pages",
		"f": "create wiki pages",
		"w": "edit tickets",
		"t": "create ticket report formats",
	} {
		if eff.HasAny(letter) {
			t.Errorf("FAIL: a Reader can %s (%s) — the role says reads, asks, discusses", what, letter)
		}
	}
	// `n`, filing a ticket in the store, is NOT asserted against here
	// and is not this role's doing: StockAnonymous is "hmnc", so
	// fossil grants ticket-filing to a passer-by and every signed-in
	// account inherits the anonymous category. Whether quarry should
	// keep that is RFC 0017's question about the store tier, not this
	// preset's. What the preset must not do is add it itself.
	if strings.ContainsRune(presetCaps(t, "Reader"), 'n') {
		t.Error("FAIL: the Reader preset now grants store ticket filing of its own")
	}
	// And it still has to be able to read, discuss, and be signed in.
	for letter, what := range map[string]string{
		"o": "read the code",
		"j": "read the wiki",
		"r": "read tickets",
		"2": "read the forum",
		"3": "post in the forum",
		"c": "comment on a ticket",
		"C": "chat",
		"8": "see CI",
		"p": "change their own password, and reach the cross-repository ops quarry gates on p",
	} {
		if !eff.HasAny(letter) {
			t.Errorf("FAIL: a Reader cannot %s (%s)", what, letter)
		}
	}
}

// The category expansion itself is untouched: `u` still pulls in
// whatever a repository says its readers get. Only the PRESET stopped
// routing through it, so a repository that deliberately widens its
// reader category still reaches the roles that keep `u`.
func TestTheReaderCategoryStillExpandsForWhoeverHoldsU(t *testing.T) {
	db := teamDB(t)
	if err := db.AddRepo("quarry", "/srv/quarry", "", ""); err != nil {
		t.Fatal(err)
	}
	if err := db.SetCategoryCaps("quarry", "reader", StockReader); err != nil {
		t.Fatal(err)
	}
	if err := db.SetCaps("lou", "quarry", presetCaps(t, "Developer")); err != nil {
		t.Fatal(err)
	}
	if eff := db.EffectiveCaps("lou", "quarry"); !eff.HasAny("k") {
		t.Error("FAIL: a Developer's u no longer reaches the reader category")
	}
	// A hand-written string holding u behaves the same — the preset is
	// only where the surprise was most likely to be met.
	if err := db.SetCaps("ann", "quarry", "u"); err != nil {
		t.Fatal(err)
	}
	if eff := db.EffectiveCaps("ann", "quarry"); !eff.HasAny("w") {
		t.Error("FAIL: a bare u no longer expands")
	}
}