package api
import (
"context"
"errors"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/ops"
)
// fakeOwners is the ownership registry as this op uses it.
type fakeOwners struct {
owner map[string]string
ns map[string]string
nsBy map[string]string
}
func newFakeOwners() *fakeOwners {
return &fakeOwners{owner: map[string]string{}, ns: map[string]string{}, nsBy: map[string]string{}}
}
func (f *fakeOwners) OwnerOf(repo string) (string, error) { return f.owner[repo], nil }
func (f *fakeOwners) SetOwner(repo, account string) error { f.owner[repo] = account; return nil }
func (f *fakeOwners) NamespaceOf(repo string) (string, error) { return f.ns[repo], nil }
func (f *fakeOwners) SetNamespace(repo, ns, by string, at time.Time) error {
f.ns[repo] = ns
f.nsBy[repo] = by
return nil
}
// fakeGrants answers GrantsFor and nothing else this op needs.
type fakeGrants struct{ grants map[string]map[string]string }
func (f *fakeGrants) Accounts() ([]Account, error) { return nil, nil }
func (f *fakeGrants) AddAccount(name, password string, a bool) error { return nil }
func (f *fakeGrants) SetPassword(name, password string) error { return nil }
func (f *fakeGrants) DeleteAccount(name string) error { return nil }
func (f *fakeGrants) Grant(account, repo, role string) error { return nil }
func (f *fakeGrants) Revoke(account, repo string) error { return nil }
func (f *fakeGrants) GrantsFor(account string) (map[string]string, error) {
return f.grants[account], nil
}
func claimRegistry(t *testing.T, owners *fakeOwners, grants *fakeGrants) *ops.Registry {
t.Helper()
r := ops.NewRegistry()
if err := registerRepoClaim(r, Deps{Owners: owners, Accounts: grants}); err != nil {
t.Fatal(err)
}
return r
}
func TestClaimTakesAForgeHeldRepository(t *testing.T) {
owners := newFakeOwners()
r := claimRegistry(t, owners, &fakeGrants{})
ctx := ops.WithUser(context.Background(), "dan")
got, err := r.Invoke(ctx, "repo.claim", repoClaimIn{Repo: "zit"})
if err != nil {
t.Fatal(err)
}
out := got.(repoClaimOut)
if out.Owner != "dan" || out.Namespace != "dan" {
t.Fatalf("out = %+v, want dan owning zit in dan's namespace", out)
}
if owners.nsBy["zit"] != "dan" {
t.Fatalf("placement recorded %q, want dan", owners.nsBy["zit"])
}
}
func TestClaimRefusesARepositorySomebodyAlreadyHolds(t *testing.T) {
owners := newFakeOwners()
owners.owner["quarry"] = "grace"
r := claimRegistry(t, owners, &fakeGrants{})
ctx := ops.WithUser(context.Background(), "dan")
_, err := r.Invoke(ctx, "repo.claim", repoClaimIn{Repo: "quarry"})
if !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid — a held repository changes hands by transfer", err)
}
if !strings.Contains(err.Error(), "transfer") {
t.Fatalf("the refusal should name transfer as the right verb, got %v", err)
}
if owners.owner["quarry"] != "grace" {
t.Fatal("a refused claim must not have moved the deed")
}
}
// Handing the deed to somebody else is allowed only when they already
// administer the repository, which is the nearest thing to consent
// available without a pending-offer flow.
func TestClaimHandsTheDeedOnlyToAnExistingAdmin(t *testing.T) {
owners := newFakeOwners()
grants := &fakeGrants{grants: map[string]map[string]string{
"dan": {"zit": "admin"},
"outsider": {},
}}
r := claimRegistry(t, owners, grants)
ctx := ops.WithUser(context.Background(), "zeitgeist")
if _, err := r.Invoke(ctx, "repo.claim", repoClaimIn{Repo: "zit", Account: "outsider"}); !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid — outsider does not administer zit", err)
}
if owners.owner["zit"] != "" {
t.Fatal("a refused hand-over must not have moved the deed")
}
got, err := r.Invoke(ctx, "repo.claim", repoClaimIn{Repo: "zit", Account: "dan"})
if err != nil {
t.Fatal(err)
}
if out := got.(repoClaimOut); out.Owner != "dan" {
t.Fatalf("owner = %q, want dan", out.Owner)
}
// Provenance records who performed the act, not who received it.
if owners.nsBy["zit"] != "zeitgeist" {
t.Fatalf("placement recorded %q, want zeitgeist", owners.nsBy["zit"])
}
}
// Claiming must not move a repository out of the team it appears under:
// the deed and the appearance are separate facts.
func TestClaimLeavesAnExistingNamespaceAlone(t *testing.T) {
owners := newFakeOwners()
owners.ns["proj"] = "hoppers"
r := claimRegistry(t, owners, &fakeGrants{})
ctx := ops.WithUser(context.Background(), "dan")
got, err := r.Invoke(ctx, "repo.claim", repoClaimIn{Repo: "proj"})
if err != nil {
t.Fatal(err)
}
out := got.(repoClaimOut)
if out.Owner != "dan" || out.Namespace != "hoppers" {
t.Fatalf("out = %+v, want dan holding proj while it still appears under hoppers", out)
}
}
func TestClaimSurfaceIsRepositoryAdmin(t *testing.T) {
r := claimRegistry(t, newFakeOwners(), &fakeGrants{})
op, ok := r.Lookup("repo.claim")
if !ok {
t.Fatal("repo.claim is not registered")
}
if op.Caps != "a" || op.Auth != ops.Token {
t.Fatalf("surface = caps %q auth %v, want a/Token — the repository's own admin, not the site's", op.Caps, op.Auth)
}
}
// deedOwners is the ownership registry for the gate below.
type deedOwners struct{ owner map[string]string }
func (d deedOwners) OwnerOf(repo string) (string, error) { return d.owner[repo], nil }
func (d deedOwners) SetOwner(repo, account string) error { d.owner[repo] = account; return nil }
func (d deedOwners) NamespaceOf(repo string) (string, error) { return d.owner[repo], nil }
func (d deedOwners) SetNamespace(a, b, c string, at time.Time) error { return nil }
// deedAccounts answers only the site-admin question this gate asks.
type deedAccounts struct{ admin string }
func (a deedAccounts) Accounts() ([]Account, error) {
return []Account{{Name: "ada"}, {Name: "grace"}, {Name: a.admin, Admin: true}}, nil
}
func (a deedAccounts) AddAccount(n, p string, ad bool) error { return nil }
func (a deedAccounts) SetPassword(n, p string) error { return nil }
func (a deedAccounts) DeleteAccount(n string) error { return nil }
func (a deedAccounts) Grant(acct, repo, role string) error { return nil }
func (a deedAccounts) Revoke(acct, repo string) error { return nil }
func (a deedAccounts) GrantsFor(acct string) (map[string]string, error) { return nil, nil }
// The `a` letter reaches everything INSIDE a repository and stops at
// ending it. A grant made so somebody could help maintain a project
// must not let them delete it.
func TestDestroyingBelongsToTheDeedNotTheRole(t *testing.T) {
d := Deps{
Owners: deedOwners{owner: map[string]string{"held": "ada", "unclaimed": ""}},
Accounts: deedAccounts{admin: "root"},
}
for _, tc := range []struct {
name, who, repo string
refused bool
}{
{"the owner may", "ada", "held", false},
{"an admin collaborator may not", "grace", "held", true},
{"a site admin still may, a known gap", "root", "held", false},
{"an unclaimed store is gated on the letter alone", "grace", "unclaimed", false},
} {
err := d.refuseUnlessTheirs(ops.WithUser(context.Background(), tc.who), tc.repo, "destroy")
if tc.refused {
if !errors.Is(err, ops.ErrForbidden) {
t.Errorf("FAIL: %s: err = %v, want ErrForbidden", tc.name, err)
continue
}
if !strings.Contains(err.Error(), "ada") {
t.Errorf("FAIL: %s: the refusal does not name the owner: %v", tc.name, err)
continue
}
} else if err != nil {
t.Errorf("FAIL: %s: err = %v, want nil", tc.name, err)
continue
}
t.Logf("ok: %s", tc.name)
}
}
// Without an ownership registry the gate cannot answer, and a gate that
// cannot answer must not refuse: a deployment with no registry behaves
// exactly as it did before the deed existed.
func TestTheDeedGateIsAbsentWithoutARegistry(t *testing.T) {
if err := (Deps{}).refuseUnlessTheirs(ops.WithUser(context.Background(), "grace"), "held", "destroy"); err != nil {
t.Fatalf("FAIL: err = %v, want nil", err)
}
t.Log("ok: no registry, no refusal")
}
// Handing on what you already hold is the transfer this build can
// express: both people have agreed, because the recipient already
// administers the repository. Taking what somebody ELSE holds stays
// refused, and the refusal names the verb that is missing.
func TestClaimHandsOnWhatYouHold(t *testing.T) {
owners := newFakeOwners()
owners.owner["pkgsrc"] = "keeper"
owners.ns["pkgsrc"] = "keeper"
grants := &fakeGrants{grants: map[string]map[string]string{"heir": {"pkgsrc": "admin"}}}
r := claimRegistry(t, owners, grants)
got, err := r.Invoke(ops.WithUser(context.Background(), "keeper"), "repo.claim",
repoClaimIn{Repo: "pkgsrc", Account: "heir"})
if err != nil {
t.Fatal(err)
}
if out := got.(repoClaimOut); out.Owner != "heir" {
t.Fatalf("owner = %q, want heir", out.Owner)
}
// The namespace was the previous owner's and is NOT rewritten: the
// deed and the appearance are separate facts, and a hand-over moves
// only one of them.
if owners.ns["pkgsrc"] != "keeper" {
t.Fatalf("namespace = %q, want keeper — handing the deed on must not move the repository", owners.ns["pkgsrc"])
}
}
func TestClaimStillRefusesTakingSomebodyElses(t *testing.T) {
owners := newFakeOwners()
owners.owner["pkgsrc"] = "keeper"
r := claimRegistry(t, owners, &fakeGrants{grants: map[string]map[string]string{
"stranger": {"pkgsrc": "admin"},
}})
_, err := r.Invoke(ops.WithUser(context.Background(), "stranger"), "repo.claim",
repoClaimIn{Repo: "pkgsrc"})
if !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid", err)
}
if !strings.Contains(err.Error(), "agreement") {
t.Fatalf("the refusal should name what is missing, got %v", err)
}
if owners.owner["pkgsrc"] != "keeper" {
t.Fatal("a refused claim moved the deed")
}
}