@@ 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) {
@@ 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()