From e6125fce57bf084c875d39b68efa434647b9ce3c Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 08:30:38 +0300 Subject: [PATCH] db: list every database a viewer may see --- db/repos.go | 36 ++++++++++++++++ db/repos_test.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/db/repos.go b/db/repos.go index 46c075c9d6fcaf62c08cf6820a931f94deae0a65..cabb1affe6dfe8dbe8eceaae0e41f1e9865102e8 100644 --- a/db/repos.go +++ b/db/repos.go @@ -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) { diff --git a/db/repos_test.go b/db/repos_test.go index 9c78b8c3a73e73b2a4d52088f65d3ecf7cf57b81..dc637a57aa1f3c8eda247575757ec0fa88039fda 100644 --- a/db/repos_test.go +++ b/db/repos_test.go @@ -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()