package api
import (
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// dbRoster is oltp as a CapsWriter: its CapsOf returns the account's
// OWN string on a repository, which is how handsWarning tells somebody
// who reaches a repository only through their team from somebody who
// would keep it anyway.
type dbRoster struct{ *oltp.DB }
func (r dbRoster) CapsOf(account, repo string) (string, error) {
return r.DB.CapsOf(account, repo)
}
func (r dbRoster) SetCaps(actor, account, repo, caps string) (string, error) {
return r.DB.SetCapsGuarded(actor, account, repo, caps)
}
// handsRig: two teams, a repository, and members who reach it only
// through their team — the population a change of hands moves.
func handsRig(t *testing.T) (*ops.Registry, *oltp.DB, *fakeEvents) {
t.Helper()
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
for _, who := range []string{"owen", "mia", "nils", "vera"} {
if err := db.AddAccount(who, "a-long-enough-password", false); err != nil {
t.Fatal(err)
}
}
ev := &fakeEvents{}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{"test": {}}),
Teams: db,
Accounts: acctAdapter{db},
Roster: dbRoster{db},
Events: ev,
CapsFor: func(a, repo string) CapSet { return db.EffectiveCaps(a, repo) }}); err != nil {
t.Fatal(err)
}
// PRIVATE, which is the case a change of hands actually moves. On a
// public repository the categories already grant everything the
// membership floor does, so putting it under a team changes nothing
// for anybody and saying nothing is the correct answer — the first
// version of this test used the default (public) categories and
// proved only that.
//
// RFC 0018 says the same thing from the other side: a private
// repository has both categories empty by definition, and teams are
// mostly used on private repositories, so the floor is the entire
// access story there.
for _, cat := range []string{"nobody", "anonymous"} {
if err := db.SetCategoryCaps("test", cat, ""); err != nil {
t.Fatal(err)
}
}
// owen administers the repository, so he may move it.
if err := db.Grant("owen", "test", oltp.RepoAdmin); err != nil {
t.Fatal(err)
}
for _, team := range []string{"alpha", "beta"} {
if _, err := r.Invoke(asUser("owen"), "team.create", teamCreateIn{Name: team}); err != nil {
t.Fatal(err)
}
}
// mia reaches it only through alpha; vera only through beta.
if _, err := r.Invoke(asUser("owen"), "team.member",
teamMemberIn{Name: "alpha", Account: "mia"}); err != nil {
t.Fatal(err)
}
if _, err := r.Invoke(asUser("owen"), "team.member",
teamMemberIn{Name: "beta", Account: "vera"}); err != nil {
t.Fatal(err)
}
// nils is in alpha AND holds a string of his own, so a move costs
// him nothing — he must not be told he lost anything.
if _, err := r.Invoke(asUser("owen"), "team.member",
teamMemberIn{Name: "alpha", Account: "nils"}); err != nil {
t.Fatal(err)
}
if err := db.SetCaps("nils", "test", "uv3bBC78"); err != nil {
t.Fatal(err)
}
return r, db, ev
}
// Putting a repository under a team grants every member access to it,
// and taking it out takes that away. account.caps raises RFC 0005's
// vocabulary and puts a line in the person's inbox for a change to ONE
// account; the team path changes access for many at once — and on a
// private repository changes the whole of it — and said nothing.
func TestAChangeOfHandsTellsThePeopleItMoved(t *testing.T) {
r, _, ev := handsRig(t)
if _, err := r.Invoke(asUser("owen"), "team.repo.add",
teamRepoIn{Name: "alpha", Repo: "test"}); err != nil {
t.Fatal(err)
}
// mia gained access, so she is told. nils held his own string
// already and gained nothing, so he is not.
added := ev.subjects()
if len(added) == 0 {
t.Fatal("FAIL: putting a repository under a team raised nothing")
}
if n := strings.Count(strings.Join(added, " "), "account.added"); n < 1 {
t.Errorf("FAIL: an arrival did not raise account.added: %v", added)
}
// Moving it to beta: alpha's members lose it, beta's gain it.
before := len(ev.subjects())
out, err := r.Invoke(asUser("owen"), "team.repo.add",
teamRepoIn{Name: "beta", Repo: "test"})
if err != nil {
t.Fatal(err)
}
moved := ev.subjects()[before:]
if len(moved) == 0 {
t.Error("FAIL: moving a repository between teams raised nothing")
}
// The answer names who loses access rather than leaving it to be
// discovered — RFC 0017's rule for unbinding, same act.
warn := out.(teamOkOut).Warning
if !strings.Contains(warn, "mia") {
t.Errorf("FAIL: the move did not name who loses access: %q", warn)
}
if strings.Contains(warn, "nils") {
t.Errorf("FAIL: the warning names somebody who loses nothing: %q", warn)
}
}
// Taking a repository out names who loses access, and dissolving a team
// does the same for every repository it held — the two paths that used
// to drop access with nothing said.
func TestRemovingAndDissolvingBothNameWhoLosesAccess(t *testing.T) {
r, db, _ := handsRig(t)
if _, err := r.Invoke(asUser("owen"), "team.repo.add",
teamRepoIn{Name: "alpha", Repo: "test"}); err != nil {
t.Fatal(err)
}
out, err := r.Invoke(asUser("owen"), "team.repo.remove",
teamRepoIn{Name: "alpha", Repo: "test"})
if err != nil {
t.Fatal(err)
}
if w := out.(teamOkOut).Warning; !strings.Contains(w, "mia") {
t.Errorf("FAIL: removal did not name who loses access: %q", w)
}
// And the dissolve path, which does not go through remove.
if _, err := r.Invoke(asUser("owen"), "team.repo.add",
teamRepoIn{Name: "alpha", Repo: "test"}); err != nil {
t.Fatal(err)
}
// Dissolving does not go through remove, so it is its own path and
// gets its own assertion: mia reaches the repository through alpha
// and must not still reach it once alpha is gone.
if !db.EffectiveCaps("mia", "test").HasAny("C") {
t.Fatal("FAIL: setup — mia does not reach the repository through alpha")
}
if _, err := r.Invoke(asUser("owen"), "team.delete", teamGetIn{Name: "alpha"}); err != nil {
t.Fatal(err)
}
if db.EffectiveCaps("mia", "test").HasAny("C") {
t.Error("FAIL: alpha is gone and mia still reaches its repository")
}
}