package db import ( "context" "errors" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) func TestCreateAndGetRepo(t *testing.T) { s, db, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() insertUser(t, db, 1, "alice", core.UserTypeUser) repo := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) if repo.ID == 0 { t.Fatal("expected non-zero repo id") } got, err := s.GetRepoByOwnerAndName(ctx, "alice", "widgets") if err != nil { t.Fatalf("get by owner/name: %v", err) } if got.ID != repo.ID || got.OwnerName != "alice" || got.Visibility != core.VisibilityPublic { t.Fatalf("unexpected repo: %+v", got) } byID, err := s.GetRepoByID(ctx, repo.ID) if err != nil { t.Fatalf("get by id: %v", err) } if byID.Name != "widgets" || byID.OwnerName != "alice" { t.Fatalf("unexpected repo by id: %+v", byID) } } // TestCreateRepoDuplicateName covers both unique indexes a same-name re-create // can trip. The "same path" case is the one production actually takes — Path is // derived from (owner, name), so a real duplicate violates both indexes and // Postgres reports the lower-OID repository_path_key. The "different path" case // only reaches uq_repo_owner_id_name; it needs the repos root to have moved // after the first row was written. func TestCreateRepoDuplicateName(t *testing.T) { for _, tc := range []struct { name string path string }{ {"same path (both indexes, path reported)", "/var/lib/dolt/~alice/widgets"}, {"different path (name index only)", "/srv/dolt/~alice/widgets"}, } { t.Run(tc.name, func(t *testing.T) { s, db, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() insertUser(t, db, 1, "alice", core.UserTypeUser) mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) _, err := s.CreateRepo(ctx, &core.Repo{ Name: "widgets", OwnerID: 1, OwnerName: "alice", Path: tc.path, Visibility: core.VisibilityPrivate, }) if !errors.Is(err, ErrNameTaken) { t.Fatalf("expected ErrNameTaken, got %v", err) } }) } } // The row's timestamps are read, not merely stored: /query publishes them, and // a projection that forgot the two columns would answer the zero time for every // database on the instance without failing anywhere. func TestRepoCarriesItsTimestamps(t *testing.T) { s, sqlDB, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() before := time.Now().UTC().Add(-time.Second) insertUser(t, sqlDB, 1, "alice", core.UserTypeUser) created := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) assert.False(t, created.Created.IsZero(), "CreateRepo must return the row it wrote") assert.Equal(t, created.Created, created.Updated, "a fresh row was never updated") for _, tc := range []struct { name string get func() (*core.Repo, error) }{ {"by id", func() (*core.Repo, error) { return s.GetRepoByID(ctx, created.ID) }}, {"by owner and name", func() (*core.Repo, error) { return s.GetRepoByOwnerAndName(ctx, "alice", "widgets") }}, {"through a listing", func() (*core.Repo, error) { repos, err := s.ListReposByOwner(ctx, "alice", nil) if err != nil { return nil, err } return repos[0], nil }}, } { t.Run(tc.name, func(t *testing.T) { got, err := tc.get() require.NoError(t, err) assert.False(t, got.Created.IsZero(), "created came back as the zero time") assert.True(t, got.Created.After(before), "created is not the row's own timestamp") assert.False(t, got.Updated.IsZero()) }) } // An update moves Updated and leaves Created where it was. require.NoError(t, s.UpdateRepo(ctx, created.ID, "now with docs", core.VisibilityPrivate)) got, err := s.GetRepoByID(ctx, created.ID) require.NoError(t, err) assert.Equal(t, created.Created.Unix(), got.Created.Unix(), "an update rewrote created") assert.False(t, got.Updated.Before(got.Created), "updated went backwards") } // A rename moves the name and the on-disk path in one statement: path is // derived from (owner, name), and a row whose two halves disagree would be // served out of the wrong store. func TestRenameRepo(t *testing.T) { s, sqlDB, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() insertUser(t, sqlDB, 1, "alice", core.UserTypeUser) repo := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) require.NoError(t, s.RenameRepo(ctx, repo.ID, "gadgets", "/var/lib/dolt/~alice/gadgets")) got, err := s.GetRepoByID(ctx, repo.ID) require.NoError(t, err) assert.Equal(t, "gadgets", got.Name) assert.Equal(t, "/var/lib/dolt/~alice/gadgets", got.Path) assert.Equal(t, core.VisibilityPublic, got.Visibility, "a rename must not touch anything else") // The old name stops resolving and the new one answers. _, err = s.GetRepoByOwnerAndName(ctx, "alice", "widgets") assert.ErrorIs(t, err, ErrNotFound) byName, err := s.GetRepoByOwnerAndName(ctx, "alice", "gadgets") require.NoError(t, err) assert.Equal(t, repo.ID, byName.ID) assert.ErrorIs(t, s.RenameRepo(ctx, 999, "x", "/var/lib/dolt/~alice/x"), ErrNotFound) } // Renaming onto a name the owner already uses trips the same pair of unique // indexes a duplicate create does, and must come back as ErrNameTaken rather // than a raw 23505 — the web layer answers 409 on that error alone. func TestRenameRepoOntoATakenName(t *testing.T) { for _, tc := range []struct { name string path string }{ {"same path (both indexes, path reported)", "/var/lib/dolt/~alice/taken"}, {"different path (name index only)", "/srv/dolt/~alice/taken"}, } { t.Run(tc.name, func(t *testing.T) { s, sqlDB, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() insertUser(t, sqlDB, 1, "alice", core.UserTypeUser) insertUser(t, sqlDB, 2, "bob", core.UserTypeUser) repo := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) mkRepo(t, s, ctx, 1, "alice", "taken", core.VisibilityPublic) assert.ErrorIs(t, s.RenameRepo(ctx, repo.ID, "taken", tc.path), ErrNameTaken) // Nothing moved: the refusal is the whole statement's. got, err := s.GetRepoByID(ctx, repo.ID) require.NoError(t, err) assert.Equal(t, "widgets", got.Name) // Uniqueness is per owner — bob's "taken" is no obstacle, and it is // the path index alone that keeps two owners apart on disk. mkRepo(t, s, ctx, 2, "bob", "widgets", core.VisibilityPublic) require.NoError(t, s.RenameRepo(ctx, repo.ID, "gadgets", "/var/lib/dolt/~alice/gadgets")) }) } } func TestGetRepoNotFound(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() if _, err := s.GetRepoByOwnerAndName(ctx, "nobody", "nope"); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound, got %v", err) } if _, err := s.GetRepoByID(ctx, 999); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound, got %v", err) } } func TestUpdateAndDeleteRepo(t *testing.T) { s, db, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() insertUser(t, db, 1, "alice", core.UserTypeUser) repo := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) if err := s.UpdateRepo(ctx, repo.ID, "now with docs", core.VisibilityPrivate); err != nil { t.Fatalf("update: %v", err) } got, err := s.GetRepoByID(ctx, repo.ID) if err != nil { t.Fatalf("get: %v", err) } if got.Description != "now with docs" || got.Visibility != core.VisibilityPrivate { t.Fatalf("update not reflected: %+v", got) } if err := s.UpdateRepo(ctx, 999, "x", core.VisibilityPublic); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound updating missing repo, got %v", err) } if err := s.DeleteRepo(ctx, repo.ID); err != nil { t.Fatalf("delete: %v", err) } if _, err := s.GetRepoByID(ctx, repo.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound after delete, got %v", err) } if err := s.DeleteRepo(ctx, repo.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound deleting twice, got %v", err) } } // TestListingVisibility exercises the listing rule: owners and ACL holders see // everything; everyone else (incl. anonymous) sees only PUBLIC; UNLISTED is // never listed to non-owners without an ACL. func TestListingVisibility(t *testing.T) { s, db, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() owner := insertUser(t, db, 1, "alice", core.UserTypeUser) aclUser := insertUser(t, db, 2, "bob", core.UserTypeUser) stranger := insertUser(t, db, 3, "carol", core.UserTypeUser) mkRepo(t, s, ctx, owner, "alice", "pub", core.VisibilityPublic) mkRepo(t, s, ctx, owner, "alice", "unl", core.VisibilityUnlisted) priv := mkRepo(t, s, ctx, owner, "alice", "priv", core.VisibilityPrivate) // bob gets RO on the private repo. if err := s.UpsertACL(ctx, priv.ID, aclUser, core.AccessRO); err != nil { t.Fatalf("grant acl: %v", err) } names := func(repos []*core.Repo) map[string]bool { m := map[string]bool{} for _, r := range repos { m[r.Name] = true } return m } // Owner sees all three. ownerCaller := &core.Caller{UserID: owner, Username: "alice", UserType: core.UserTypeUser} got, err := s.ListReposByOwner(ctx, "alice", ownerCaller) if err != nil { t.Fatalf("list owner: %v", err) } if n := names(got); !n["pub"] || !n["unl"] || !n["priv"] || len(got) != 3 { t.Fatalf("owner should see all three, got %v", n) } // Anonymous sees only PUBLIC. got, err = s.ListReposByOwner(ctx, "alice", nil) if err != nil { t.Fatalf("list anon: %v", err) } if n := names(got); !n["pub"] || n["unl"] || n["priv"] || len(got) != 1 { t.Fatalf("anon should see only pub, got %v", n) } // Stranger (no ACL) sees only PUBLIC. strangerCaller := &core.Caller{UserID: stranger, Username: "carol", UserType: core.UserTypeUser} got, err = s.ListReposByOwner(ctx, "alice", strangerCaller) if err != nil { t.Fatalf("list stranger: %v", err) } if n := names(got); !n["pub"] || n["unl"] || n["priv"] || len(got) != 1 { t.Fatalf("stranger should see only pub, got %v", n) } // ACL holder sees PUBLIC plus the private repo they were granted (not UNLISTED). aclCaller := &core.Caller{UserID: aclUser, Username: "bob", UserType: core.UserTypeUser} got, err = s.ListReposByOwner(ctx, "alice", aclCaller) if err != nil { t.Fatalf("list acl user: %v", err) } if n := names(got); !n["pub"] || !n["priv"] || n["unl"] || len(got) != 2 { t.Fatalf("acl user should see pub+priv, got %v", n) } } // 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() ctx := context.Background() owner := insertUser(t, db, 1, "alice", core.UserTypeUser) other := insertUser(t, db, 2, "bob", core.UserTypeUser) own := mkRepo(t, s, ctx, owner, "alice", "mine", core.VisibilityPrivate) shared := mkRepo(t, s, ctx, other, "bob", "shared", core.VisibilityPrivate) mkRepo(t, s, ctx, other, "bob", "hidden", core.VisibilityPrivate) if err := s.UpsertACL(ctx, shared.ID, owner, core.AccessRW); err != nil { t.Fatalf("grant: %v", err) } got, err := s.ListReposForDashboard(ctx, owner) if err != nil { t.Fatalf("dashboard: %v", err) } found := map[string]bool{} for _, r := range got { found[r.Name] = true } if !found["mine"] || !found["shared"] || found["hidden"] || len(got) != 2 { t.Fatalf("dashboard should list own+acl only, got %v", found) } _ = own }