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