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
}