package oltp
import (
"path/filepath"
"strings"
"testing"
)
func teamDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
for _, who := range []string{"dan", "lou", "ann"} {
if err := db.AddAccount(who, "pw-"+who+"-long-enough", false); err != nil {
t.Fatal(err)
}
}
return db
}
// A team is born with an owner. Without this a team exists that nobody
// can manage: a set of grants nobody can see or revoke, repairable only
// by a site admin.
func TestATeamIsBornWithAnOwner(t *testing.T) {
db := teamDB(t)
if err := db.AddTeam("release-eng", "Release engineering", "ships things", "dan"); err != nil {
t.Fatal(err)
}
if !db.IsTeamOwner("release-eng", "dan") {
t.Error("FAIL: the creator is not an owner of the team they made")
}
members, err := db.TeamMembers("release-eng")
if err != nil {
t.Fatal(err)
}
if len(members) != 1 || members[0].Account != "dan" || !members[0].Owner {
t.Errorf("FAIL: members = %+v", members)
}
// Listed by default (RFC 0018: Forgejo's rule), with an opt-out.
if !members[0].Listed {
t.Error("FAIL: a new member is not listed publicly by default")
}
}
// Teams and accounts share one namespace, both ways. A grant row names
// a string, and if that string could be either kind the forge would
// have to guess on every resolution.
func TestTheNamespaceIsSharedBothWays(t *testing.T) {
db := teamDB(t)
if err := db.AddTeam("dan", "", "", "dan"); err == nil {
t.Error("FAIL: a team took a name an account holds")
}
if err := db.AddTeam("release-eng", "", "", "dan"); err != nil {
t.Fatal(err)
}
if err := db.AddAccount("release-eng", "a-long-enough-password", false); err == nil {
t.Error("FAIL: an account took a name a team holds")
}
if err := db.AddTeam("release-eng", "", "", "lou"); err == nil {
t.Error("FAIL: a second team took the same name")
}
}
// Membership ALONE carries the floor. This is RFC 0018's new source of
// authority — it arrives from membership with no grant behind it — and
// the reason a page must be able to say "by membership" as a route.
func TestMembershipAloneCarriesTheFloor(t *testing.T) {
db := teamDB(t)
// A private repository: both categories empty, so there is nothing
// for a floor to lean on. This is the common case for teams.
if err := db.SetVisibility("secret", "Private"); err != nil {
t.Fatal(err)
}
if got := db.EffectiveCaps("lou", "secret"); got != "" {
t.Fatalf("FAIL: a stranger already holds %q on a private repo", got)
}
if err := db.AddTeam("core", "", "", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetTeamRepo("core", "secret", "dan"); err != nil {
t.Fatal(err)
}
// No caps of their own: the floor and nothing more.
if err := db.SetTeamMember("core", "lou", "", false, true); err != nil {
t.Fatal(err)
}
eff := db.EffectiveCaps("lou", "secret")
for _, letter := range teamFloor {
if !strings.ContainsRune(string(eff), letter) {
t.Errorf("FAIL: the floor is missing %q from %q", letter, eff)
}
}
}
// Read everything, participate freely, pollute nothing. Every letter
// that writes the repository's own store is absent.
func TestTheFloorWritesNothingToTheStore(t *testing.T) {
db := teamDB(t)
if err := db.SetVisibility("secret", "Private"); err != nil {
t.Fatal(err)
}
if err := db.AddTeam("core", "", "", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetTeamRepo("core", "secret", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetTeamMember("core", "lou", "", false, true); err != nil {
t.Fatal(err)
}
eff := string(db.EffectiveCaps("lou", "secret"))
for _, bad := range []struct {
letter rune
what string
}{
{'i', "check in"}, {'n', "file a store ticket"}, {'c', "comment on a store ticket"},
{'3', "post in the store's forum"}, {'m', "append to the wiki"},
{'k', "edit the wiki"}, {'f', "create wiki pages"}, {'w', "edit tickets"},
{'a', "change settings"}, {'b', "attach files"}, {'B', "attach to forum posts"},
} {
if strings.ContainsRune(eff, bad.letter) {
t.Errorf("FAIL: the floor can %s (%q in %q)", bad.what, bad.letter, eff)
}
}
}
// A team grant is a union on top of the floor, and never a cap: it
// cannot reduce what somebody already held directly.
func TestATeamGrantAddsAndNeverSubtracts(t *testing.T) {
db := teamDB(t)
if err := db.SetVisibility("repo", "Private"); err != nil {
t.Fatal(err)
}
// lou already holds admin directly.
if err := db.SetCaps("lou", "repo", "a"); err != nil {
t.Fatal(err)
}
if err := db.AddTeam("core", "", "", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetTeamRepo("core", "repo", "dan"); err != nil {
t.Fatal(err)
}
// A team row that grants far less than they hold.
if err := db.SetTeamMember("core", "lou", "", false, true); err != nil {
t.Fatal(err)
}
if !db.EffectiveCaps("lou", "repo").HasAny("a") {
t.Error("FAIL: a team row took away an admin grant somebody held directly")
}
// And a team row's own letters arrive.
if err := db.SetTeamMember("core", "ann", "i", false, true); err != nil {
t.Fatal(err)
}
if !db.EffectiveCaps("ann", "repo").HasAny("i") {
t.Error("FAIL: a team row's own letters did not arrive")
}
}
// A repository is under exactly one team, enforced by the schema.
// Adding it to a second MOVES it, which is RFC 0018's transfer.
func TestARepositoryIsUnderExactlyOneTeam(t *testing.T) {
db := teamDB(t)
for _, name := range []string{"core", "docs"} {
if err := db.AddTeam(name, "", "", "dan"); err != nil {
t.Fatal(err)
}
}
if err := db.SetTeamRepo("core", "quarry", "dan"); err != nil {
t.Fatal(err)
}
if got := db.TeamOfRepo("quarry"); got != "core" {
t.Fatalf("FAIL: team of quarry = %q", got)
}
if err := db.SetTeamRepo("docs", "quarry", "dan"); err != nil {
t.Fatal(err)
}
if got := db.TeamOfRepo("quarry"); got != "docs" {
t.Errorf("FAIL: after the move, team of quarry = %q, want docs", got)
}
left, err := db.TeamRepos("core")
if err != nil {
t.Fatal(err)
}
if len(left) != 0 {
t.Errorf("FAIL: the old team still holds %+v — it was a copy, not a move", left)
}
}
// Taking a repository out takes back exactly what the team gave, and
// nothing else.
func TestRemovingARepositoryTakesBackOnlyTheTeamsGrant(t *testing.T) {
db := teamDB(t)
if err := db.SetVisibility("repo", "Private"); err != nil {
t.Fatal(err)
}
if err := db.SetCaps("ann", "repo", "a"); err != nil {
t.Fatal(err)
}
if err := db.AddTeam("core", "", "", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetTeamRepo("core", "repo", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetTeamMember("core", "lou", "", false, true); err != nil {
t.Fatal(err)
}
if err := db.RemoveTeamRepo("repo"); err != nil {
t.Fatal(err)
}
if got := db.EffectiveCaps("lou", "repo"); got != "" {
t.Errorf("FAIL: lou still holds %q after the repository left the team", got)
}
if !db.EffectiveCaps("ann", "repo").HasAny("a") {
t.Error("FAIL: ann's own direct admin went with the team's grant")
}
}
// A team must never reach zero owners. Same guard, same shape and same
// site-admin exception as GuardCaps.
func TestATeamNeverReachesZeroOwners(t *testing.T) {
db := teamDB(t)
if err := db.AddTeam("core", "", "", "dan"); err != nil {
t.Fatal(err)
}
if _, err := db.GuardTeamMember("dan", "core", "dan", false, true); !IsRefusal(err) {
t.Errorf("FAIL: the last owner could remove themselves: %v", err)
}
if _, err := db.GuardTeamMember("dan", "core", "dan", false, false); !IsRefusal(err) {
t.Errorf("FAIL: the last owner could demote themselves: %v", err)
}
// With a second owner, either may go.
if err := db.SetTeamMember("core", "lou", "", true, true); err != nil {
t.Fatal(err)
}
if warn, err := db.GuardTeamMember("dan", "core", "dan", false, true); err != nil || warn != "" {
t.Errorf("FAIL: leaving with a second owner present was refused: %v / %q", err, warn)
}
}
// A site admin is TOLD rather than refused — GuardCaps' exception, for
// the same reason: a forge admin can always reach in, so refusing them
// would only mean doing it in two steps.
func TestASiteAdminIsToldNotRefused(t *testing.T) {
db := teamDB(t)
if err := db.AddAccount("root", "a-long-enough-password", true); err != nil {
t.Fatal(err)
}
if err := db.AddTeam("core", "", "", "dan"); err != nil {
t.Fatal(err)
}
warn, err := db.GuardTeamMember("root", "core", "dan", false, true)
if err != nil {
t.Fatalf("FAIL: a site admin was refused: %v", err)
}
if warn == "" {
t.Error("FAIL: a site admin was not told the team would have no owner")
}
}
// Deleting a team takes its bindings, never the repositories.
func TestDeletingATeamKeepsTheRepositories(t *testing.T) {
db := teamDB(t)
if err := db.AddTeam("core", "", "", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetTeamRepo("core", "quarry", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetCaps("lou", "quarry", "a"); err != nil {
t.Fatal(err)
}
if err := db.DeleteTeam("core"); err != nil {
t.Fatal(err)
}
if got := db.TeamOfRepo("quarry"); got != "" {
t.Errorf("FAIL: quarry is still under %q", got)
}
if !db.EffectiveCaps("lou", "quarry").HasAny("a") {
t.Error("FAIL: deleting the team took away a direct grant on one of its repositories")
}
if _, found := db.TeamByName("core"); found {
t.Error("FAIL: the team is still there")
}
}
// A repository under no team is the ordinary case and costs nothing.
func TestARepositoryUnderNoTeamIsUnaffected(t *testing.T) {
db := teamDB(t)
if err := db.SetVisibility("lonely", "Private"); err != nil {
t.Fatal(err)
}
if got := db.EffectiveCaps("lou", "lonely"); got != "" {
t.Errorf("FAIL: %q on a private repo under no team", got)
}
if got := db.TeamOfRepo("lonely"); got != "" {
t.Errorf("FAIL: team of an unteamed repo = %q", got)
}
}
// Team carries its counts, so every read that returns one must fill
// them. team.get returned "members": 0 beside a list holding one
// member — an unfilled field renders as a fact, which is the same bug
// as a template printing an absent count as nothing.
func TestASingleTeamReadCarriesItsCounts(t *testing.T) {
db := teamDB(t)
if err := db.AddTeam("core", "Core", "", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetTeamMember("core", "lou", "", false, true); err != nil {
t.Fatal(err)
}
if err := db.SetTeamRepo("core", "quarry", "dan"); err != nil {
t.Fatal(err)
}
got, found := db.TeamByName("core")
if !found {
t.Fatal("FAIL: no team")
}
if got.Members != 2 {
t.Errorf("FAIL: members = %d, want 2 (dan as creator-owner, lou)", got.Members)
}
if got.Repos != 1 {
t.Errorf("FAIL: repos = %d, want 1", got.Repos)
}
// And the listing still agrees with the single read.
list, err := db.Teams()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Members != got.Members || list[0].Repos != got.Repos {
t.Errorf("FAIL: listing %+v disagrees with the single read %+v", list, got)
}
}
// The three name pools have to be disjoint at the top path segment, or
// creating a team is a way to re-point somebody else's URL.
//
// The case, found by the session doing namespaced paths: a repository
// "quarry" exists, so /r/quarry/zit means that repository with "zit"
// beneath it. Create a team called "quarry", put zit under it, and
// zit's namespace becomes exactly "quarry" — so a resolver reading
// /r/A/B as namespaced-when-B's-namespace-is-A now sends that URL to a
// different repository. Team creation is an ordinary action for any
// signed-in account, which makes it user-creatable link re-pointing.
func TestANameCannotCollideAcrossTheThreePools(t *testing.T) {
db := teamDB(t)
if err := db.AddRepo("quarry", "/srv/quarry", "", ""); err != nil {
t.Fatal(err)
}
if err := db.AddTeam("quarry", "", "", "dan"); err == nil {
t.Error("FAIL: a team may be named after a repository — an existing URL can be re-pointed")
}
if err := db.AddAccount("quarry", "a-long-enough-password", false); err == nil {
t.Error("FAIL: an account may be named after a repository")
}
// The pre-existing directions still hold, so this is a third line
// in a place already doing the job and not a replacement.
if err := db.AddTeam("dan", "", "", "dan"); err == nil {
t.Error("FAIL: a team may be named after an account")
}
if err := db.AddTeam("core", "", "", "dan"); err != nil {
t.Fatalf("FAIL: a name colliding with nothing was refused: %v", err)
}
if err := db.AddAccount("core", "a-long-enough-password", false); err == nil {
t.Error("FAIL: an account may be named after a team")
}
}