package remoteapi
import (
"context"
"errors"
"testing"
remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
"github.com/sirupsen/logrus"
"github.com/vaughan0/go-ini"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
"sourcecraft.dev/bigbes/sr-ht-dolt/storage"
)
// errBoom is a sentinel failure injected into createStore to exercise the
// push-to-create rollback path.
var errBoom = errors.New("boom")
const (
mRoot = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Root"
mGetMeta = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetRepoMetadata"
mCommit = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Commit"
mUpload = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetUploadLocations"
mDownload = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetDownloadLocations"
mUnknown = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Frobnicate"
)
// fakeReq implements repoRequest with a fixed path/id so tests need not depend
// on which concrete proto message carries the repo for a given method.
type fakeReq struct {
path string
id *remotesapi.RepoId
}
func (f fakeReq) GetRepoPath() string { return f.path }
func (f fakeReq) GetRepoId() *remotesapi.RepoId { return f.id }
// stubStore is an in-memory repoStore: no Postgres. It is a pointer receiver so
// tests can inspect recorded auto-create calls after authorize runs.
type stubStore struct {
repo *core.Repo
repoErr error
acl *core.AccessMode
aclErr error
// createErr, when set, is returned by CreateRepo (e.g. db.ErrNameTaken to
// simulate losing the first-push race).
createErr error
// refetchRepo is returned by GetRepoByOwnerAndName after a CreateRepo that
// failed with ErrNameTaken (the concurrent winner's row). When nil, repo is
// returned instead.
refetchRepo *core.Repo
// Recorded calls.
created *core.Repo // the repo passed to CreateRepo
createCalls int
deletedID int
deleteCalls int
getCalls int
}
func (s *stubStore) GetRepoByOwnerAndName(_ context.Context, _, _ string) (*core.Repo, error) {
s.getCalls++
// After a CreateRepo lost the ErrNameTaken race, the re-fetch resolves the
// concurrent winner's row.
if s.createCalls > 0 && s.refetchRepo != nil {
return s.refetchRepo, nil
}
if s.repoErr != nil {
return nil, s.repoErr
}
return s.repo, nil
}
func (s *stubStore) EffectiveAccess(_ context.Context, _, _ int) (*core.AccessMode, error) {
return s.acl, s.aclErr
}
func (s *stubStore) CreateRepo(_ context.Context, r *core.Repo) (*core.Repo, error) {
s.createCalls++
s.created = r
if s.createErr != nil {
return nil, s.createErr
}
out := *r
out.ID = 999
return &out, nil
}
func (s *stubStore) DeleteRepo(_ context.Context, id int) error {
s.deleteCalls++
s.deletedID = id
return nil
}
func testInterceptor(store repoStore) *interceptor {
return &interceptor{
conf: ini.File{},
service: "dolt.sr.ht",
expectedAud: "dolt.srht.bigb.es",
logger: logrus.NewEntry(logrus.New()),
stores: func() repoStore { return store },
reposRoot: "/tmp/repos",
createStore: func(context.Context, string) error { return nil },
}
}
func repo(id, ownerID int, vis core.Visibility) *core.Repo {
return &core.Repo{ID: id, Name: "db", OwnerID: ownerID, OwnerName: "alice", Visibility: vis}
}
func acl(m core.AccessMode) *core.AccessMode { return &m }
// patCaller builds an authenticated PAT caller with the given grant string
// (empty ⇒ universal token). config context is needed by DecodeGrants.
func patCaller(t *testing.T, userID int, grants string) *auth.AuthContext {
t.Helper()
ctx := config.Context(context.Background(), ini.File{}, "dolt.sr.ht")
g, err := auth.DecodeGrants(ctx, grants)
if err != nil {
t.Fatalf("DecodeGrants(%q): %v", grants, err)
}
return &auth.AuthContext{
UserID: userID,
Username: "alice",
UserType: auth.USER_TYPE_USER,
AuthMethod: auth.AUTH_OAUTH2,
BearerToken: &auth.BearerToken{},
Grants: g,
}
}
// cookieCaller builds an authenticated non-token caller (cookie/keypair): no
// BearerToken, so the grant gate passes trivially.
func cookieCaller(userID int) *auth.AuthContext {
return &auth.AuthContext{
UserID: userID,
Username: "alice",
UserType: auth.USER_TYPE_USER,
AuthMethod: auth.AUTH_COOKIE,
}
}
func wantCode(t *testing.T, err error, code codes.Code) {
t.Helper()
if status.Code(err) != code {
t.Fatalf("want gRPC code %s, got %s (err=%v)", code, status.Code(err), err)
}
}
func TestClassify(t *testing.T) {
cases := []struct {
method string
op core.Op
mode core.AccessMode
ok bool
}{
{mCommit, core.OpPush, core.AccessRW, true},
{mUpload, core.OpPush, core.AccessRW, true},
{mRoot, core.OpCloneRead, core.AccessRO, true},
{mGetMeta, core.OpCloneRead, core.AccessRO, true},
{mDownload, core.OpCloneRead, core.AccessRO, true},
{mUnknown, 0, "", false},
}
for _, c := range cases {
op, mode, ok := classify(c.method)
if ok != c.ok || (ok && (op != c.op || mode != c.mode)) {
t.Errorf("classify(%s) = (%v,%v,%v), want (%v,%v,%v)",
c.method, op, mode, ok, c.op, c.mode, c.ok)
}
}
}
func TestAuthorize(t *testing.T) {
ctx := context.Background()
req := fakeReq{path: "~alice/db"}
t.Run("anonymous public read allowed", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
if _, err := i.authorize(ctx, nil, mGetMeta, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("anonymous public push denied (visible)", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
_, err := i.authorize(ctx, nil, mCommit, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("anonymous private read is not found", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPrivate)})
_, err := i.authorize(ctx, nil, mGetMeta, req)
wantCode(t, err, codes.NotFound)
})
t.Run("owner push allowed", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate)})
if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("non-owner private read no acl is not found", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate)})
_, err := i.authorize(ctx, cookieCaller(7), mGetMeta, req)
wantCode(t, err, codes.NotFound)
})
t.Run("acl RO reads private", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)})
if _, err := i.authorize(ctx, cookieCaller(7), mGetMeta, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("acl RO cannot push private (visible ⇒ PermissionDenied)", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)})
_, err := i.authorize(ctx, cookieCaller(7), mCommit, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("acl RW pushes private", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRW)})
if _, err := i.authorize(ctx, cookieCaller(7), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("unknown method denied", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
_, err := i.authorize(ctx, nil, mUnknown, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("malformed path is invalid argument", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
_, err := i.authorize(ctx, nil, mGetMeta, fakeReq{path: "no-slash"})
wantCode(t, err, codes.InvalidArgument)
})
t.Run("missing repo row is not found", func(t *testing.T) {
i := testInterceptor(&stubStore{repoErr: db.ErrNotFound})
_, err := i.authorize(ctx, nil, mGetMeta, req)
wantCode(t, err, codes.NotFound)
})
t.Run("root ping with no path is allowed", func(t *testing.T) {
i := testInterceptor(&stubStore{})
got, err := i.authorize(ctx, nil, mRoot, fakeReq{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil {
t.Fatal("expected a caller context")
}
})
t.Run("non-root with no path is invalid argument", func(t *testing.T) {
i := testInterceptor(&stubStore{})
_, err := i.authorize(ctx, nil, mGetMeta, fakeReq{})
wantCode(t, err, codes.InvalidArgument)
})
t.Run("repo id shape resolves path", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
r := fakeReq{id: &remotesapi.RepoId{Org: "alice", RepoName: "db"}}
if _, err := i.authorize(ctx, nil, mGetMeta, r); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestAuthorizeGrantGate(t *testing.T) {
ctx := context.Background()
req := fakeReq{path: "~alice/db"}
t.Run("PAT without RO grant denied on read", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
_, err := i.authorize(ctx, patCaller(t, 42, "meta.sr.ht/profile:RO"), mGetMeta, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("PAT with RO grant allowed on read of own repo", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mGetMeta, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("PAT with RO grant cannot push", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
_, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mCommit, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("PAT with RW grant pushes own repo", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RW"), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("universal PAT (empty grants) passes gate", func(t *testing.T) {
i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
if _, err := i.authorize(ctx, patCaller(t, 42, ""), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
// TestAuthorizePushToCreate covers the push-to-create branch of authorize: an
// authenticated owner touching a not-yet-existing repo in their own namespace
// transparently creates it (PRIVATE row + empty on-disk store), while every
// other case keeps returning NotFound and creates nothing.
func TestAuthorizePushToCreate(t *testing.T) {
ctx := context.Background()
req := fakeReq{path: "~alice/newdb"}
t.Run("owner create on GetRepoMetadata read", func(t *testing.T) {
st := &stubStore{repoErr: db.ErrNotFound}
var gotPath string
i := testInterceptor(st)
i.createStore = func(_ context.Context, p string) error { gotPath = p; return nil }
// The first RPC of a push is GetRepoMetadata (a read); auto-create fires.
if _, err := i.authorize(ctx, cookieCaller(42), mGetMeta, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if st.createCalls != 1 {
t.Fatalf("CreateRepo calls = %d, want 1", st.createCalls)
}
if st.created == nil || st.created.Visibility != core.VisibilityPrivate {
t.Fatalf("created repo visibility = %v, want PRIVATE", st.created)
}
if st.created.OwnerID != 42 || st.created.Name != "newdb" || st.created.OwnerName != "alice" {
t.Fatalf("created repo = %+v, want owner 42 alice/newdb", st.created)
}
wantPath := storage.RepoDiskPath("/tmp/repos", "alice", "newdb")
if st.created.Path != wantPath || gotPath != wantPath {
t.Fatalf("store path: created=%q createStore=%q, want %q", st.created.Path, gotPath, wantPath)
}
if st.deleteCalls != 0 {
t.Fatalf("DeleteRepo calls = %d, want 0", st.deleteCalls)
}
})
t.Run("owner create then push allowed", func(t *testing.T) {
st := &stubStore{repoErr: db.ErrNotFound}
i := testInterceptor(st)
if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if st.createCalls != 1 {
t.Fatalf("CreateRepo calls = %d, want 1", st.createCalls)
}
})
t.Run("name-taken race re-fetches, no store creation", func(t *testing.T) {
winner := &core.Repo{ID: 7, Name: "newdb", OwnerID: 42, OwnerName: "alice", Visibility: core.VisibilityPrivate}
st := &stubStore{repoErr: db.ErrNotFound, createErr: db.ErrNameTaken, refetchRepo: winner}
var storeCreated bool
i := testInterceptor(st)
i.createStore = func(context.Context, string) error { storeCreated = true; return nil }
if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if st.createCalls != 1 {
t.Fatalf("CreateRepo calls = %d, want 1", st.createCalls)
}
if storeCreated {
t.Fatal("createStore must not be called when the row already exists")
}
if st.deleteCalls != 0 {
t.Fatalf("DeleteRepo calls = %d, want 0", st.deleteCalls)
}
})
t.Run("store creation failure rolls back row", func(t *testing.T) {
st := &stubStore{repoErr: db.ErrNotFound}
i := testInterceptor(st)
i.createStore = func(context.Context, string) error { return errBoom }
_, err := i.authorize(ctx, cookieCaller(42), mCommit, req)
wantCode(t, err, codes.Unavailable)
if st.deleteCalls != 1 || st.deletedID != 999 {
t.Fatalf("rollback: deleteCalls=%d deletedID=%d, want 1 and 999", st.deleteCalls, st.deletedID)
}
})
t.Run("non-owner namespace is not found, no create", func(t *testing.T) {
st := &stubStore{repoErr: db.ErrNotFound}
i := testInterceptor(st)
// caller bob touching ~alice/newdb.
bob := &auth.AuthContext{UserID: 7, Username: "bob", UserType: auth.USER_TYPE_USER, AuthMethod: auth.AUTH_COOKIE}
_, err := i.authorize(ctx, bob, mCommit, req)
wantCode(t, err, codes.NotFound)
if st.createCalls != 0 {
t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls)
}
})
t.Run("anonymous is not found, no create", func(t *testing.T) {
st := &stubStore{repoErr: db.ErrNotFound}
i := testInterceptor(st)
_, err := i.authorize(ctx, nil, mCommit, req)
wantCode(t, err, codes.NotFound)
if st.createCalls != 0 {
t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls)
}
})
t.Run("suspended owner is not found, no create", func(t *testing.T) {
st := &stubStore{repoErr: db.ErrNotFound}
i := testInterceptor(st)
suspended := &auth.AuthContext{UserID: 42, Username: "alice", UserType: auth.USER_TYPE_SUSPENDED, AuthMethod: auth.AUTH_COOKIE}
_, err := i.authorize(ctx, suspended, mCommit, req)
wantCode(t, err, codes.NotFound)
if st.createCalls != 0 {
t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls)
}
})
t.Run("invalid name is not found, no create", func(t *testing.T) {
st := &stubStore{repoErr: db.ErrNotFound}
i := testInterceptor(st)
// "bad name" passes ParseRepoPath (2 non-traversal segments) but fails
// core.ValidateName (space is not an allowed character).
_, err := i.authorize(ctx, cookieCaller(42), mCommit, fakeReq{path: "~alice/bad name"})
wantCode(t, err, codes.NotFound)
if st.createCalls != 0 {
t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls)
}
})
}
// TestUnaryAnonymous exercises the full unary path (authenticate + authorize)
// for an anonymous request: no metadata ⇒ anonymous caller, no crypto/PG.
func TestUnaryAnonymous(t *testing.T) {
i := &interceptor{
conf: ini.File{},
service: "dolt.sr.ht",
logger: logrus.NewEntry(logrus.New()),
stores: func() repoStore { return &stubStore{repo: repo(1, 100, core.VisibilityPublic)} },
}
var gotCaller bool
handler := func(ctx context.Context, _ any) (any, error) {
// The handler must see a caller-carrying context (anonymous ⇒ nil ac).
gotCaller = authn.CallerFromContext(ctx) == nil
return "ok", nil
}
info := &grpc.UnaryServerInfo{FullMethod: mGetMeta}
resp, err := i.unary()(context.Background(), fakeReq{path: "~alice/db"}, info, handler)
if err != nil {
t.Fatalf("unary: %v", err)
}
if resp != "ok" || !gotCaller {
t.Fatalf("handler not invoked with anonymous caller context (resp=%v)", resp)
}
}
func TestNormalizeAud(t *testing.T) {
cases := map[string]string{
"dolt.srht.bigb.es": "dolt.srht.bigb.es",
"dolt.srht.bigb.es:443": "dolt.srht.bigb.es",
"127.0.0.1:5306": "127.0.0.1",
"": "",
}
for in, want := range cases {
if got := normalizeAud(in); got != want {
t.Errorf("normalizeAud(%q) = %q, want %q", in, got, want)
}
}
}