Skip to content
quarry Artifact [20e69b9162]
Login

Artifact [20e69b9162]

Artifact 20e69b9162607be4e4bbeb3efd2b04c954d8008a76f423ab50a72c9c7a40d116:


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) SetOperator(string, string) error                     { return nil }
func (f *fakeGrants) DeleteAccount(name string) error                      { return nil }
func (f *fakeGrants) Grant(string, string, string, string) (string, error) { return "", nil }
func (f *fakeGrants) Revoke(string, string, string) (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) SetOperator(string, string) error                     { return nil }
func (a deedAccounts) DeleteAccount(n string) error                         { return nil }
func (a deedAccounts) Grant(string, string, string, string) (string, error) { return "", nil }
func (a deedAccounts) Revoke(string, string, string) (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"
	// Under a TEAM, which is the namespace a hand-over must not move.
	// This asserted a personal namespace staying put when it was first
	// written, which was the bug rather than the rule: that left the
	// repository appearing under an account that no longer held it.
	owners.ns["pkgsrc"] = "hoppers"
	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 team still holds the path. A team holds where a repository
	// appears, never who holds it, so a hand-over says nothing about it.
	if owners.ns["pkgsrc"] != "hoppers" {
		t.Fatalf("namespace = %q, want hoppers — a hand-over must not move a repository out of its team", 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")
	}
}

// A personal namespace follows the deed; a team's does not. The first
// half was found by using the op: pkgsrc changed hands and went on
// appearing under the account that no longer held it.
func TestHandingOnMovesAPersonalNamespaceButNotATeamsp(t *testing.T) {
	for _, tc := range []struct {
		name, startNS, wantNS string
	}{
		{"a personal namespace follows the deed", "keeper", "heir"},
		{"a team keeps the repository", "hoppers", "hoppers"},
		{"an empty namespace is filled in", "", "heir"},
	} {
		owners := newFakeOwners()
		owners.owner["proj"] = "keeper"
		if tc.startNS != "" {
			owners.ns["proj"] = tc.startNS
		}
		r := claimRegistry(t, owners, &fakeGrants{grants: map[string]map[string]string{
			"heir": {"proj": "admin"},
		}})
		got, err := r.Invoke(ops.WithUser(context.Background(), "keeper"), "repo.claim",
			repoClaimIn{Repo: "proj", Account: "heir"})
		if err != nil {
			t.Fatalf("FAIL: %s: %v", tc.name, err)
		}
		out := got.(repoClaimOut)
		if out.Owner != "heir" {
			t.Errorf("FAIL: %s: owner = %q, want heir", tc.name, out.Owner)
			continue
		}
		if out.Namespace != tc.wantNS {
			t.Errorf("FAIL: %s: namespace = %q, want %q", tc.name, out.Namespace, tc.wantNS)
			continue
		}
		t.Logf("ok: %s", tc.name)
	}
}