Skip to content
quarry Artifact [bd3de92ee5]
Login

Artifact [bd3de92ee5]

Artifact bd3de92ee5482732c8ad351ce01c285148a3c762db92980ed0def6c16e4ef40c:


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) }

// yes is an explicit true for the tri-state fields: nil means "not
// mentioned", so a test that means to SAY true has to say it.
func yes() *bool { b := true; return &b }

// no is an explicit false, for the same reason.
func no() *bool { b := false; return &b }

// 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 {
		// Email included: the production adapter carries it
		// (registrar.accountStore.Accounts), and mailAccount resolves
		// the address through exactly this list — so dropping it here
		// made every transactional message in a team test silently
		// unsendable, and provable only by accident.
		out = append(out, Account{Name: r.Name, Admin: r.Admin, Created: r.Created,
			Email: r.Email, EmailPending: r.EmailPending})
	}
	return out, nil
}

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

func (a acctAdapter) Revoke(actor, account, repo string) (string, error) {
	return a.DB.GrantGuarded(actor, account, repo, oltp.NoRole)
}

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: yes(), Listed: yes()})
	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: yes()}); 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 ask. She does not own core, so this
	// is an OFFER and not a move (RFC 0018 §"The receiving half is an
	// offer") — holding `a` says nobody can take the repository from
	// her, not that she may hand core work it never asked for.
	if err := db.SetCaps("ann", "test", "a"); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(asUser("ann"), "team.repo.add",
		teamRepoIn{Name: "core", Repo: "test"})
	if err != nil {
		t.Fatalf("FAIL: the repository's admin was refused: %v", err)
	}
	res := out.(teamOkOut)
	if !res.Offered {
		t.Errorf("FAIL: the result does not say it was only offered: %+v", res)
	}
	if got := db.TeamOfRepo("test"); got != "" {
		t.Fatalf("FAIL: an offer moved the repository to %q", got)
	}
	// lou owns core and accepts. Only now does anything move.
	if _, err := r.Invoke(asUser("lou"), "team.repo.offer.accept",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatalf("FAIL: the team's owner could not accept: %v", err)
	}
	if got := db.TeamOfRepo("test"); got != "core" {
		t.Errorf("FAIL: team of test = %q", got)
	}
}

// An owner of BOTH sides has nobody to consult, so their add takes
// effect at once rather than becoming a question they answer
// themselves.
func TestOwningBothSidesTakesEffectAtOnce(t *testing.T) {
	r, db := teamRig(t)
	if _, err := r.Invoke(asUser("ann"), "team.create", teamCreateIn{Name: "core"}); 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.add",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatal(err)
	}
	if got := db.TeamOfRepo("test"); got != "core" {
		t.Errorf("FAIL: an owner of both sides was made to wait: team = %q", got)
	}
}

// An offer is consent from the TEAM's side, not a standing grant from
// the repository's: somebody who has lost `a` in the days since cannot
// have their week-old offer accepted on their behalf.
func TestAnOfferLapsesWithTheCapabilityBehindIt(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.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.Fatal(err)
	}
	// ann is no longer an admin of test.
	if err := db.SetCaps("ann", "test", ""); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("lou"), "team.repo.offer.accept",
		teamRepoIn{Name: "core", Repo: "test"}); err == nil {
		t.Fatal("FAIL: a lapsed offer was accepted")
	}
	if got := db.TeamOfRepo("test"); got != "" {
		t.Errorf("FAIL: the repository moved on lapsed authority, to %q", got)
	}
	// And the dead offer is gone rather than left to be retried.
	if _, ok, err := db.TeamOfferFor("test"); err != nil || ok {
		t.Errorf("FAIL: the lapsed offer is still pending (ok=%v err=%v)", ok, err)
	}
}

// Declining leaves no state: the repository is where it was, and the
// same offer may be made again.
func TestDecliningLeavesNoState(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.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.Fatal(err)
	}
	if _, err := r.Invoke(asUser("lou"), "team.repo.offer.decline",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatalf("FAIL: the team's owner could not decline: %v", err)
	}
	if _, ok, err := db.TeamOfferFor("test"); err != nil || ok {
		t.Errorf("FAIL: a declined offer is still pending (ok=%v err=%v)", ok, err)
	}
	// Askable again — a decline is not a register of who refused whom.
	if _, err := r.Invoke(asUser("ann"), "team.repo.add",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Errorf("FAIL: the offer could not be made again: %v", err)
	}
}

// Whoever MADE an offer may withdraw it without asking the team to
// decline for them.
func TestTheOffererMayWithdraw(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.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.Fatal(err)
	}
	if _, err := r.Invoke(asUser("ann"), "team.repo.offer.decline",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatalf("FAIL: the offerer could not withdraw: %v", err)
	}
	if _, ok, _ := db.TeamOfferFor("test"); ok {
		t.Error("FAIL: the withdrawn offer is still pending")
	}
}

// Nobody else may answer for the team.
func TestAStrangerMayNotAcceptOrDecline(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.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.Fatal(err)
	}
	for _, op := range []string{"team.repo.offer.accept", "team.repo.offer.decline"} {
		if _, err := r.Invoke(asUser("eve"), op,
			teamRepoIn{Name: "core", Repo: "test"}); !errors.Is(err, ops.ErrForbidden) {
			t.Errorf("FAIL: a stranger reached %s: %v", op, err)
		}
	}
	if got := db.TeamOfRepo("test"); got != "" {
		t.Errorf("FAIL: the repository moved, to %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", Listed: no()}); 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: yes()}); err != nil {
		t.Fatal(err)
	}
	if n := seen(asUser("dan")); n != 1 {
		t.Errorf("FAIL: a listed membership is still withheld: %d", n)
	}
}

// A count is read beside the list it describes, so it has to withhold
// whatever the list withholds. Counting every member made the public
// number disagree with the public list, which told a stranger that
// somebody had opted out and how many had — the fact the opt-out
// exists to withhold, on the surface it exists to govern. Every test
// that asserted the LIST passed while this was broken, because the
// list was right.
func TestThePublicCountMatchesThePublicList(t *testing.T) {
	r, _ := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	// lou owns it and is listed; ann opts out. Two members, one shown.
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "core", Account: "ann", Listed: no()}); err != nil {
		t.Fatal(err)
	}

	get := func(ctx context.Context) teamGetOut {
		out, err := r.Invoke(ctx, "team.get", teamGetIn{Name: "core"})
		if err != nil {
			t.Fatal(err)
		}
		return out.(teamGetOut)
	}
	pub := get(asUser("dan"))
	if pub.Team.Members != len(pub.Members) {
		t.Errorf("FAIL: public count %d beside a list of %d — the gap names the opt-out",
			pub.Team.Members, len(pub.Members))
	}
	if pub.Team.Unlisted != 0 {
		t.Errorf("FAIL: a stranger is told %d members opted out", pub.Team.Unlisted)
	}

	// The manager sees the whole list, and a count that matches it.
	mine := get(asUser("lou"))
	if len(mine.Members) != 2 {
		t.Fatalf("FAIL: the owner cannot see the unlisted member: %d", len(mine.Members))
	}
	if mine.Team.Unlisted != 1 {
		t.Errorf("FAIL: the owner is not told anybody opted out: %d", mine.Team.Unlisted)
	}
	if mine.Team.Members+mine.Team.Unlisted != len(mine.Members) {
		t.Errorf("FAIL: %d listed + %d unlisted does not describe a list of %d",
			mine.Team.Members, mine.Team.Unlisted, len(mine.Members))
	}

	// team.people is Anonymous and carries the same count.
	po, err := r.Invoke(asUser("dan"), "team.people", teamPageIn{Name: "core", Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	pp := po.(teamPeopleOut)
	if pp.Team.Unlisted != 0 {
		t.Errorf("FAIL: team.people discloses %d opt-outs to a stranger", pp.Team.Unlisted)
	}
	members := 0
	for _, p := range pp.People {
		if p.Member {
			members++
		}
	}
	if pp.Team.Members != members {
		t.Errorf("FAIL: team.people says %d members beside %d shown", pp.Team.Members, members)
	}

	// The directory is uniformly public: it shows a count with no list,
	// so it never carries the opt-out, not even to a manager.
	lo, err := r.Invoke(asUser("lou"), "team.list", teamListIn{})
	if err != nil {
		t.Fatal(err)
	}
	for _, tm := range lo.(teamListOut).Teams {
		if tm.Unlisted != 0 {
			t.Errorf("FAIL: the directory carries an opt-out count for %s", tm.Name)
		}
	}
}

// An omitted field means "I did not mention this", not "set this to
// false". Both halves of this were live defects found by using the op
// on production rather than by any test here:
//
//   - `listed` carried validate:"default=1", which parsed and published
//     itself in the documentation and then did nothing: defaults are
//     applied by the form and query codec only, a JSON body goes
//     straight through json.Unmarshal, and applyDefault had no bool
//     case either way. Two members added over the API landed silently
//     unlisted — withheld from the team page and absent from its count.
//   - `owner` was a plain bool on an UPSERT, so changing an owner's
//     caps without mentioning `owner` demoted them and answered ok.
//     GuardTeamMember saved the last owner; a team with two lost one.
func TestAnOmittedFieldKeepsWhatTheMemberHad(t *testing.T) {
	r, db := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}

	// A NEW member, nothing said about listing: listed is the default,
	// because the opt-out is the member's own choice and must not be
	// an artefact of how they were added.
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "core", Account: "ann"}); err != nil {
		t.Fatal(err)
	}
	m, ok := db.TeamMemberOf("core", "ann")
	if !ok || !m.Listed {
		t.Errorf("FAIL: a member added without `listed` is unlisted (ok=%v listed=%v)", ok, m.Listed)
	}
	if m.Owner {
		t.Error("FAIL: a member added without `owner` is an owner")
	}

	// She opts out, then somebody changes her role without mentioning
	// listing. Her choice survives.
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "core", Account: "ann", Listed: no()}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "core", Account: "ann", Caps: oltp.Maintain.Caps()}); err != nil {
		t.Fatal(err)
	}
	if m, _ := db.TeamMemberOf("core", "ann"); m.Listed {
		t.Error("FAIL: a caps change undid the member's opt-out")
	}

	// Two owners, so the guard is not what saves the second one. Change
	// ann's caps without mentioning ownership: she stays an owner.
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "core", Account: "ann", Owner: yes()}); err != nil {
		t.Fatal(err)
	}
	if !db.IsTeamOwner("core", "ann") {
		t.Fatal("FAIL: setup — ann is not an owner")
	}
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "core", Account: "ann", Caps: ""}); err != nil {
		t.Fatal(err)
	}
	if !db.IsTeamOwner("core", "ann") {
		t.Error("FAIL: a caps change silently demoted an owner")
	}

	// And an explicit false still means false, or the fix would have
	// made the fields unsettable.
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "core", Account: "ann", Owner: no(), Listed: no()}); err != nil {
		t.Fatal(err)
	}
	if db.IsTeamOwner("core", "ann") {
		t.Error("FAIL: an explicit owner:false did not demote")
	}
	if m, _ := db.TeamMemberOf("core", "ann"); m.Listed {
		t.Error("FAIL: an explicit listed:false did not opt out")
	}
}

// The candidate list is the add control's whole security surface, so it
// must answer the same question team.repo.add does and not a friendlier
// one. The escalation this file exists to prevent (see its header) is:
// make a team, put somebody else's repository under it, grant yourself
// admin of it. If Donatable offered a repository the caller does not
// administer, the page would draw that attack as a menu item.
func TestDonatableOffersOnlyWhatTheCallerAdministers(t *testing.T) {
	r, db := teamRig(t)
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	donatable := func(ctx context.Context) []string {
		out, err := r.Invoke(ctx, "team.get", teamGetIn{Name: "core"})
		if err != nil {
			t.Fatal(err)
		}
		return out.(teamGetOut).Donatable
	}

	// lou owns the team but holds nothing on "test": the team gives no
	// route to it, which is the point.
	if got := donatable(asUser("lou")); len(got) != 0 {
		t.Errorf("FAIL: a team owner is offered %v without administering it", got)
	}

	// dan administers it, so it is his to donate — and offering it is
	// only honest if the op would actually accept it.
	if err := db.Grant("dan", "test", oltp.RepoAdmin); err != nil {
		t.Fatal(err)
	}
	if got := donatable(asUser("dan")); len(got) != 1 || got[0] != "test" {
		t.Fatalf("FAIL: an admin is not offered their own repository: %v", got)
	}
	if _, err := r.Invoke(asUser("dan"), "team.repo.add",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatalf("FAIL: the op refused what the listing offered: %v", err)
	}

	// dan does not own core, so that was an OFFER. It comes off the
	// list at once all the same: a control that invites the same
	// question a second time is not a control.
	if got := donatable(asUser("dan")); len(got) != 0 {
		t.Errorf("FAIL: a repository already offered is still offered: %v", got)
	}
	if _, err := r.Invoke(asUser("lou"), "team.repo.offer.accept",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatal(err)
	}

	// Once held, it is off the list — for him and for everybody. An
	// "add" that silently moved a repository out of another team would
	// not be an add.
	if got := donatable(asUser("dan")); len(got) != 0 {
		t.Errorf("FAIL: a repository already under a team is still offered: %v", got)
	}

	// An anonymous reader gets no list at all: it is a per-account
	// answer and there is nothing they could do with it.
	if got := donatable(context.Background()); len(got) != 0 {
		t.Errorf("FAIL: an anonymous reader is offered %v", got)
	}
}

// Removal is per row because the right to it is per row, and the page
// draws one control per repository from this answer.
func TestMayRemoveIsPerRepositoryNotPerTeam(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.Grant("dan", "test", oltp.RepoAdmin); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("dan"), "team.repo.add",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatal(err)
	}
	// dan offers; lou accepts. Removal is about a repository the team
	// actually holds, so the offer has to be answered first.
	if _, err := r.Invoke(asUser("lou"), "team.repo.offer.accept",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatal(err)
	}
	mayRemove := func(ctx context.Context) []string {
		out, err := r.Invoke(ctx, "team.get", teamGetIn{Name: "core"})
		if err != nil {
			t.Fatal(err)
		}
		return out.(teamGetOut).MayRemove
	}
	// Both sides may withdraw: the team's owner, and the repository's
	// own admin — who must be able to, or they have lost control of
	// their own access list.
	if got := mayRemove(asUser("lou")); len(got) != 1 {
		t.Errorf("FAIL: the team owner cannot remove it: %v", got)
	}
	if got := mayRemove(asUser("dan")); len(got) != 1 {
		t.Errorf("FAIL: the repository's admin cannot remove it: %v", got)
	}
	// ann is neither.
	if got := mayRemove(asUser("ann")); len(got) != 0 {
		t.Errorf("FAIL: a bystander is offered a remove control: %v", got)
	}
}

// A windowed listing and the badge beside it describe different
// populations, and the badge must describe the whole one. len() of a
// page silently becomes the page size, which is the same
// count-disagrees-with-its-list bug this area has already produced in
// team.get and in team.people.
func TestTheDirectoryTotalIsNotThePageSize(t *testing.T) {
	r, _ := teamRig(t)
	for _, name := range []string{"a-core", "b-infra", "c-web", "d-ops", "e-data"} {
		if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: name}); err != nil {
			t.Fatal(err)
		}
	}
	list := func(limit, offset int) teamListOut {
		out, err := r.Invoke(asUser("lou"), "team.list",
			teamListIn{Limit: limit, Offset: offset})
		if err != nil {
			t.Fatal(err)
		}
		return out.(teamListOut)
	}
	first := list(2, 0)
	if len(first.Teams) != 2 {
		t.Fatalf("FAIL: a window of 2 returned %d", len(first.Teams))
	}
	if first.Total != 5 {
		t.Errorf("FAIL: total is %d, not the 5 teams that exist — the badge would read the page size", first.Total)
	}
	// Walking the windows visits every team once, in order.
	var seen []string
	for off := 0; off < first.Total; off += 2 {
		for _, tm := range list(2, off).Teams {
			seen = append(seen, tm.Name)
		}
	}
	if len(seen) != 5 {
		t.Fatalf("FAIL: the windows visited %d teams, not 5: %v", len(seen), seen)
	}
	for i, want := range []string{"a-core", "b-infra", "c-web", "d-ops", "e-data"} {
		if seen[i] != want {
			t.Errorf("FAIL: position %d is %q, want %q", i, seen[i], want)
		}
	}
	// Past the end is an empty page, not a wrapped one.
	if got := list(2, 99); len(got.Teams) != 0 {
		t.Errorf("FAIL: offset past the end returned %d rows", len(got.Teams))
	}

	// A per-account listing has been filtered, so its total is what
	// survived the filter and not the forge's team count.
	if _, err := r.Invoke(asUser("lou"), "team.member",
		teamMemberIn{Name: "a-core", Account: "ann"}); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(asUser("ann"), "team.list", teamListIn{Account: "ann"})
	if err != nil {
		t.Fatal(err)
	}
	mine := out.(teamListOut)
	if mine.Total != len(mine.Teams) || mine.Total != 1 {
		t.Errorf("FAIL: a filtered listing reports total %d beside %d rows",
			mine.Total, len(mine.Teams))
	}
}

// A pending offer must not become a way to learn that a private
// repository exists: the same rule visibility follows everywhere else.
//
// The rig wires CanRead itself. teamRig leaves it nil, which means
// "every repository is readable", and a visibility test against an
// unwired rig proves nothing about the op — the board's own test says
// so, having passed a leak that way once.
func TestAnOfferDoesNotLeakAPrivateRepository(t *testing.T) {
	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{"lou", "ann"} {
		if err := db.AddAccount(who, "a-long-enough-password", false); err != nil {
			t.Fatal(err)
		}
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos:    testRepos(wikiIndex(t)),
		Teams:    db,
		Accounts: acctAdapter{db},
		CanRead: func(ctx context.Context, repo string) bool {
			return db.EffectiveCaps(ops.User(ctx), repo).HasAny("o")
		},
		CapsFor: func(a, repo string) CapSet { return db.EffectiveCaps(a, repo) }}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("lou"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	if err := db.SetCaps("ann", "test", "ao"); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("ann"), "team.repo.add",
		teamRepoIn{Name: "core", Repo: "test"}); err != nil {
		t.Fatal(err)
	}
	offered := func(ctx context.Context) []oltp.TeamOffer {
		out, err := r.Invoke(ctx, "team.get", teamGetIn{Name: "core"})
		if err != nil {
			t.Fatal(err)
		}
		return out.(teamGetOut).Offered
	}
	// ann can read it, so she sees what she offered.
	if got := offered(asUser("ann")); len(got) != 1 {
		t.Fatalf("FAIL: the offerer cannot see her own offer: %v", got)
	}
	// Private — both stock categories emptied, which is what a private
	// repository IS (RFC 0004). An anonymous reader learns nothing.
	for _, cat := range []string{"nobody", "anonymous"} {
		if err := db.SetCategoryCaps("test", cat, ""); err != nil {
			t.Fatal(err)
		}
	}
	if got := offered(context.Background()); len(got) != 0 {
		t.Errorf("FAIL: an offer named a private repository to a stranger: %v", got)
	}
}

// teamMailRig is teamRig with a mail plane attached, so what a team
// change TELLS somebody can be asserted rather than assumed.
func teamMailRig(t *testing.T) (*ops.Registry, *oltp.DB, *fakeMail) {
	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.SetEmail(who, who+"@example.org"); err != nil {
			t.Fatal(err)
		}
	}
	mail := &fakeMail{}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos:    testRepos(wikiIndex(t)),
		Teams:    db,
		Accounts: acctAdapter{db},
		Mail:     mail,
		CapsFor:  func(a, repo string) CapSet { return db.EffectiveCaps(a, repo) },
	}); err != nil {
		t.Fatal(err)
	}
	return r, db, mail
}

// A team grants a capability floor on every repository under it, so
// being added to one can hand somebody access to work they have never
// seen. They are told.
func TestBeingAddedToATeamMailsThePerson(t *testing.T) {
	r, _, mail := teamMailRig(t)
	if _, err := r.Invoke(asUser("dan"), "team.create",
		teamCreateIn{Name: "core", Title: "Core"}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("dan"), "team.member",
		teamMemberIn{Name: "core", Account: "lou"}); err != nil {
		t.Fatal(err)
	}
	if !mail.to("[email protected]") {
		t.Errorf("FAIL the new member was not told: %+v", mail.sent)
	} else {
		t.Log("ok the new member is mailed")
	}
}

// team.member is also how an owner edits the caps or the listing of
// somebody already in the team. Mailing on those would turn one
// decision into a stream of notices about flags they never chose.
func TestChangingAnExistingMembershipMailsNobody(t *testing.T) {
	r, _, mail := teamMailRig(t)
	if _, err := r.Invoke(asUser("dan"), "team.create",
		teamCreateIn{Name: "core", Title: "Core"}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("dan"), "team.member",
		teamMemberIn{Name: "core", Account: "lou"}); err != nil {
		t.Fatal(err)
	}
	before := len(mail.sent)
	for _, in := range []teamMemberIn{
		{Name: "core", Account: "lou", Caps: "v"},
		{Name: "core", Account: "lou", Listed: no()},
		{Name: "core", Account: "lou", Owner: yes()},
	} {
		if _, err := r.Invoke(asUser("dan"), "team.member", in); err != nil {
			t.Fatal(err)
		}
	}
	if len(mail.sent) != before {
		t.Errorf("FAIL a membership edit mailed %d extra: %+v", len(mail.sent)-before, mail.sent[before:])
	} else {
		t.Log("ok editing an existing membership mails nobody")
	}
}

// Nobody needs a message about their own action.
func TestAddingYourselfToATeamMailsNobody(t *testing.T) {
	r, _, mail := teamMailRig(t)
	if _, err := r.Invoke(asUser("dan"), "team.create",
		teamCreateIn{Name: "core", Title: "Core"}); err != nil {
		t.Fatal(err)
	}
	before := len(mail.sent)
	if _, err := r.Invoke(asUser("dan"), "team.member",
		teamMemberIn{Name: "core", Account: "dan", Caps: "v"}); err != nil {
		t.Fatal(err)
	}
	if len(mail.sent) != before {
		t.Errorf("FAIL self-addition mailed: %+v", mail.sent[before:])
	} else {
		t.Log("ok adding yourself mails nobody")
	}
}