~bigbes/sr-ht-dolt

e6125fce57bf084c875d39b68efa434647b9ce3c — Eugene Blikh 5 days ago 6459298
db: list every database a viewer may see
2 files changed, 146 insertions(+), 0 deletions(-)

M db/repos.go
M db/repos_test.go
M db/repos.go => db/repos.go +36 -0
@@ 134,6 134,42 @@ ORDER BY r.created DESC, r.id DESC`
	return s.queryRepos(ctx, q, ownerUsername, viewerID)
}

// ListReposForViewer lists every repository viewer is allowed to see listed,
// across all owners, newest first. It answers the instance-wide question — "what
// may this caller be shown?" — that neither sibling can: ListReposByOwner asks
// the same listing question but only about one owner's repositories, and
// ListReposForDashboard asks a narrower question (what does this user own or
// hold an ACL on) that omits every PUBLIC repository belonging to someone else.
// Consumers that must not silently understate the instance — a cross-owner
// listing tool, the cross-database ready page — need this one.
//
// The listing rule is exactly ListReposByOwner's, minus the owner filter:
//
//   - The owner, and any user holding an ACL entry on a repo, see it regardless
//     of visibility (including PRIVATE and UNLISTED).
//   - Everyone else sees only PUBLIC repositories. UNLISTED ones stay reachable
//     by direct address — that is a browse question, not a listing one — but are
//     never listed to a stranger, and PRIVATE ones are never listed either.
//
// Anonymous (viewer == nil) is an ordinary caller here, not an error: it gets
// the PUBLIC set. An instance with nothing public yields an empty result and no
// error.
func (s *Store) ListReposForViewer(ctx context.Context, viewer *core.Caller) ([]*core.Repo, error) {
	// Anonymous is spelled as user id 0, which no mirrored user row can carry
	// (ids come from meta and are positive), so both identity branches below are
	// simply false for it and only the PUBLIC branch can match.
	viewerID := 0
	if viewer != nil {
		viewerID = viewer.UserID
	}
	q := repoSelect + `
WHERE r.visibility = 'PUBLIC'
   OR r.owner_id = $1
   OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $1)
ORDER BY r.created DESC, r.id DESC`
	return s.queryRepos(ctx, q, viewerID)
}

// ListReposForDashboard lists every repository the given user owns or holds an
// ACL entry on, newest first. Used for the signed-in user's dashboard.
func (s *Store) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) {

M db/repos_test.go => db/repos_test.go +110 -0
@@ 5,6 5,9 @@ import (
	"errors"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)



@@ 185,6 188,113 @@ func TestListingVisibility(t *testing.T) {
	}
}

// TestListReposForViewer exercises the instance-wide listing rule across
// several owners: PUBLIC is listed to everyone including an anonymous viewer,
// while UNLISTED and PRIVATE reach only their owner and the users holding an ACL
// entry on them. The expectations are exact ordered sets — "owner/name", newest
// first — so both the membership rule and the ordering are checked.
func TestListReposForViewer(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	alice := insertUser(t, db, 1, "alice", core.UserTypeUser)
	bob := insertUser(t, db, 2, "bob", core.UserTypeUser)
	carol := insertUser(t, db, 3, "carol", core.UserTypeUser)
	dave := insertUser(t, db, 4, "dave", core.UserTypeUser)

	// Two owners, each with one repository per visibility. Creation order is
	// also the expected reverse of the listing order.
	mkRepo(t, s, ctx, alice, "alice", "pub", core.VisibilityPublic)
	mkRepo(t, s, ctx, alice, "alice", "unl", core.VisibilityUnlisted)
	alicePriv := mkRepo(t, s, ctx, alice, "alice", "priv", core.VisibilityPrivate)
	bobPub := mkRepo(t, s, ctx, bob, "bob", "pub", core.VisibilityPublic)
	bobUnl := mkRepo(t, s, ctx, bob, "bob", "unl", core.VisibilityUnlisted)
	mkRepo(t, s, ctx, bob, "bob", "priv", core.VisibilityPrivate)

	// dave is the ACL grantee: RO on one owner's PRIVATE repo, RW on the
	// other's UNLISTED one. Neither grant may leak any other repository.
	require.NoError(t, s.UpsertACL(ctx, alicePriv.ID, dave, core.AccessRO))
	require.NoError(t, s.UpsertACL(ctx, bobUnl.ID, dave, core.AccessRW))
	// An ACL on a PUBLIC repo must not duplicate it in the result.
	require.NoError(t, s.UpsertACL(ctx, bobPub.ID, carol, core.AccessRO))

	caller := func(id int, name string) *core.Caller {
		return &core.Caller{UserID: id, Username: name, UserType: core.UserTypeUser}
	}

	for _, tc := range []struct {
		name   string
		viewer *core.Caller
		want   []string
	}{
		{
			name:   "anonymous sees every PUBLIC repo and nothing else",
			viewer: nil,
			want:   []string{"bob/pub", "alice/pub"},
		},
		{
			name:   "stranger sees the same PUBLIC set as anonymous",
			viewer: caller(carol, "carol"),
			want:   []string{"bob/pub", "alice/pub"},
		},
		{
			name:   "ACL grantee additionally sees the repos granted to them",
			viewer: caller(dave, "dave"),
			want:   []string{"bob/unl", "bob/pub", "alice/priv", "alice/pub"},
		},
		{
			name:   "owner sees all of their own plus other owners' PUBLIC",
			viewer: caller(alice, "alice"),
			want:   []string{"bob/pub", "alice/priv", "alice/unl", "alice/pub"},
		},
		{
			name:   "the other owner sees their own plus the first owner's PUBLIC",
			viewer: caller(bob, "bob"),
			want:   []string{"bob/priv", "bob/unl", "bob/pub", "alice/pub"},
		},
		{
			name:   "a caller with no rows of their own still sees PUBLIC",
			viewer: &core.Caller{UserID: 999, Username: "ghost", UserType: core.UserTypeUser},
			want:   []string{"bob/pub", "alice/pub"},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got, err := s.ListReposForViewer(ctx, tc.viewer)
			require.NoError(t, err)
			assert.Equal(t, tc.want, qualifiedNames(got))
		})
	}
}

// TestListReposForViewerEmpty checks that an instance with no repositories at
// all is an empty result rather than an error, for both an anonymous and a
// signed-in viewer.
func TestListReposForViewerEmpty(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	alice := insertUser(t, db, 1, "alice", core.UserTypeUser)

	got, err := s.ListReposForViewer(ctx, nil)
	require.NoError(t, err)
	assert.Empty(t, got)

	got, err = s.ListReposForViewer(ctx, &core.Caller{UserID: alice, Username: "alice", UserType: core.UserTypeUser})
	require.NoError(t, err)
	assert.Empty(t, got)
}

// qualifiedNames renders a listing as "owner/name" entries, preserving order.
func qualifiedNames(repos []*core.Repo) []string {
	out := make([]string, 0, len(repos))
	for _, r := range repos {
		out = append(out, r.OwnerName+"/"+r.Name)
	}
	return out
}

func TestListReposForDashboard(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()