Skip to content
quarry Artifact [1d83fffaf1]
Login

Artifact [1d83fffaf1]

Artifact 1d83fffaf1f2f17f2d176d367732231987b989c9dae29a49d8872b7a8fedf66e:


package api

import (
	"context"
	"errors"
	"path/filepath"
	"testing"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// teamRig wires the real OLTP against the ops registry: the guard, the
// namespace and the floor are all in oltp, so a fake store here would
// test the wiring and nothing that matters.
func teamRig(t *testing.T) (*ops.Registry, *oltp.DB) {
	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{"dan", "lou", "ann"} {
		if err := db.AddAccount(who, "a-long-enough-password", false); err != nil {
			t.Fatal(err)
		}
	}
	if err := db.AddAccount("root", "a-long-enough-password", true); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos:    testRepos(wikiIndex(t)),
		Teams:    db,
		Accounts: acctAdapter{db},
		CapsFor:  func(a, repo string) CapSet { return db.EffectiveCaps(a, repo) },
	}); err != nil {
		t.Fatal(err)
	}
	return r, db
}

func asUser(user string) context.Context { return ops.WithUser(context.Background(), user) }

// acctAdapter is the same shape cmd/quarry needs: AccountStore is
// stated in this package's own types, so the OLTP's slice has to be
// converted. Embedding carries every other method unchanged.
type acctAdapter struct{ *oltp.DB }

func (a acctAdapter) Accounts() ([]Account, error) {
	rows, err := a.DB.Accounts()
	if err != nil {
		return nil, err
	}
	out := make([]Account, 0, len(rows))
	for _, r := range rows {
		out = append(out, Account{Name: r.Name, Admin: r.Admin, Created: r.Created})
	}
	return out, nil
}

func (a acctAdapter) Grant(account, repo, role string) error {
	return a.DB.Grant(account, repo, oltp.Role(0))
}

func (a acctAdapter) GrantsFor(account string) (map[string]string, error) {
	got, err := a.DB.GrantsFor(account)
	if err != nil {
		return nil, err
	}
	out := map[string]string{}
	for repo, role := range got {
		out[repo] = role.String()
	}
	return out, nil
}

// Any signed-in account may make a team, and becomes its first owner in
// the same operation — repo.add's precedent, and what stops a team
// existing that nobody can manage.
func TestAnyAccountMakesATeamAndOwnsIt(t *testing.T) {
	r, db := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create",
		teamCreateIn{Name: "core", Title: "Core", Descr: "the middle of it"}); err != nil {
		t.Fatalf("FAIL: an ordinary account could not make a team: %v", err)
	}
	if !db.IsTeamOwner("core", "lou") {
		t.Error("FAIL: the creator is not an owner")
	}
	out, err := r.Invoke(asUser("lou"), "team.get", teamGetIn{Name: "core"})
	if err != nil {
		t.Fatal(err)
	}
	got := out.(teamGetOut)
	if got.Team.Title != "Core" || got.Team.Descr != "the middle of it" {
		t.Errorf("FAIL: team = %+v", got.Team)
	}
	if !got.CanManage {
		t.Error("FAIL: the owner cannot manage their own team")
	}
}

// The listing is public: a quarry team sits at the level other forges
// call an organisation, and that level is publicly listable everywhere.
func TestTheTeamListingIsPublic(t *testing.T) {
	r, _ := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(context.Background(), "team.list", teamListIn{})
	if err != nil {
		t.Fatalf("FAIL: an anonymous caller could not list teams: %v", err)
	}
	if got := out.(teamListOut).Teams; len(got) != 1 || got[0].Name != "core" {
		t.Errorf("FAIL: teams = %+v", got)
	}
}

// Only an owner or a site admin manages a team.
func TestOnlyAnOwnerOrSiteAdminManagesATeam(t *testing.T) {
	r, _ := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	_, err := r.Invoke(asUser("ann"), "team.member",
		teamMemberIn{Name: "core", Account: "ann", Owner: true, Listed: true})
	if !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: a stranger changed a team's membership: %v", err)
	}
	// A site admin may.
	if _, err := r.Invoke(asUser("root"), "team.member",
		teamMemberIn{Name: "core", Account: "ann", Listed: true}); err != nil {
		t.Errorf("FAIL: a site admin was refused: %v", err)
	}
}

// Putting a repository under a team needs `a` on THAT REPOSITORY, never
// the team's authority. Otherwise making a team is an escalation
// primitive: create one, take somebody else's repository, grant
// yourself admin.
func TestPuttingARepoUnderATeamNeedsAdminOfTheRepo(t *testing.T) {
	r, db := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	// lou owns the team but holds nothing on the repository.
	_, err := r.Invoke(asUser("lou"), "team.repo.add", teamRepoIn{Name: "core", Repo: "test"})
	if !errors.Is(err, ops.ErrForbidden) {
		t.Fatalf("FAIL: a team owner took a repository they do not administer: %v", err)
	}
	if got := db.TeamOfRepo("test"); got != "" {
		t.Fatalf("FAIL: the repository moved anyway, to %q", got)
	}
	// ann administers it, and may.
	if err := db.SetCaps("ann", "test", "a"); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("ann"), "team.repo.add",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatalf("FAIL: the repository's admin was refused: %v", err)
	}
	if got := db.TeamOfRepo("test"); got != "core" {
		t.Errorf("FAIL: team of test = %q", got)
	}
}

// Either side may take a repository out: whoever can grant must be able
// to revoke, or a repository admin loses control of their own access
// list.
func TestEitherSideMayTakeARepoOut(t *testing.T) {
	r, db := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	if err := db.SetTeamRepo("core", "test", "root"); err != nil {
		t.Fatal(err)
	}
	// The team's owner, holding nothing on the repository, may.
	if _, err := r.Invoke(asUser("lou"), "team.repo.remove",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatalf("FAIL: the team's owner could not withdraw it: %v", err)
	}
	// And the repository's admin, who is in no team, may.
	if err := db.SetTeamRepo("core", "test", "root"); err != nil {
		t.Fatal(err)
	}
	if err := db.SetCaps("ann", "test", "a"); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("ann"), "team.repo.remove",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatalf("FAIL: the repository's admin could not withdraw it: %v", err)
	}
	if got := db.TeamOfRepo("test"); got != "" {
		t.Errorf("FAIL: still under %q", got)
	}
	// A stranger to both may not.
	if err := db.SetTeamRepo("core", "test", "root"); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("dan"), "team.repo.remove",
		teamRepoIn{Name: "core", Repo: "test"}); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: a stranger withdrew it: %v", err)
	}
}

// An unlisted member is withheld from callers who cannot manage the
// team, and shown to those who can — they are the person who has to.
func TestAnUnlistedMemberIsWithheldFromThePublic(t *testing.T) {
	r, db := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	if err := db.SetTeamMember("core", "ann", "", false, false); err != nil {
		t.Fatal(err)
	}
	pub, err := r.Invoke(context.Background(), "team.get", teamGetIn{Name: "core"})
	if err != nil {
		t.Fatal(err)
	}
	for _, m := range pub.(teamGetOut).Members {
		if m.Account == "ann" {
			t.Error("FAIL: an opted-out member is listed publicly")
		}
	}
	mine, err := r.Invoke(asUser("lou"), "team.get", teamGetIn{Name: "core"})
	if err != nil {
		t.Fatal(err)
	}
	var seen bool
	for _, m := range mine.(teamGetOut).Members {
		if m.Account == "ann" {
			seen = true
		}
	}
	if !seen {
		t.Error("FAIL: an owner cannot see their own unlisted member")
	}
}

// The guard reaches the op: a change that would leave no owner is
// refused as a client error, not a server one.
func TestTheLastOwnerCannotLeaveThroughTheOp(t *testing.T) {
	r, _ := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	_, err := r.Invoke(asUser("lou"), "team.unmember", teamMemberIn{Name: "core", Account: "lou"})
	if !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: the last owner left: %v", err)
	}
}

// A forge with no teams plane answers unavailable rather than
// pretending.
func TestWithoutATeamsPlaneTheDoorSaysSo(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil {
		t.Fatal(err)
	}
	_, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"})
	if !errors.Is(err, ops.ErrUnavailable) {
		t.Errorf("FAIL: err = %v, want unavailable", err)
	}
}

// Deleting a team is an owner's, and leaves the repositories alone.
func TestDeletingATeamIsAnOwnersAndKeepsTheRepos(t *testing.T) {
	r, db := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	if err := db.SetTeamRepo("core", "test", "root"); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("ann"), "team.delete", teamGetIn{Name: "core"}); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: a stranger deleted a team: %v", err)
	}
	if _, err := r.Invoke(asUser("lou"), "team.delete", teamGetIn{Name: "core"}); err != nil {
		t.Fatalf("FAIL: the owner could not delete it: %v", err)
	}
	if got := db.TeamOfRepo("test"); got != "" {
		t.Errorf("FAIL: the binding survived: %q", got)
	}
}

// A per-account listing is what the person page asks for, and it has to
// honour the opt-out: an unlisted membership is withheld from strangers,
// shown to the member, and shown to whoever manages the team — because
// a manager is who has to act on it. Anything else would make the
// person page a way around a choice the team's own page respects.
func TestAnAccountListingHonoursTheOptOut(t *testing.T) {
	r, _ := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "core", Account: "ann"}); err != nil {
		t.Fatal(err)
	}
	seen := func(ctx context.Context) int {
		out, err := r.Invoke(ctx, "team.list", teamListIn{Account: "ann"})
		if err != nil {
			t.Fatal(err)
		}
		return len(out.(teamListOut).Teams)
	}
	if n := seen(asUser("dan")); n != 0 {
		t.Errorf("FAIL: a stranger sees %d unlisted membership(s)", n)
	}
	if n := seen(asUser("ann")); n != 1 {
		t.Errorf("FAIL: ann cannot see her own membership: %d", n)
	}
	if n := seen(asUser("lou")); n != 1 {
		t.Errorf("FAIL: the team's owner cannot see the membership: %d", n)
	}

	// Listed, and it is everybody's to see.
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "core", Account: "ann", Listed: true}); err != nil {
		t.Fatal(err)
	}
	if n := seen(asUser("dan")); n != 1 {
		t.Errorf("FAIL: a listed membership is still withheld: %d", n)
	}
}